@grinev/opencode-telegram-bot 0.4.0 → 0.6.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 +11 -0
- package/README.md +26 -15
- package/dist/bot/commands/definitions.js +7 -4
- package/dist/bot/commands/help.js +7 -1
- package/dist/bot/commands/new.js +4 -0
- package/dist/bot/commands/projects.js +18 -9
- package/dist/bot/commands/rename.js +49 -1
- package/dist/bot/commands/sessions.js +15 -2
- package/dist/bot/commands/stop.js +3 -5
- package/dist/bot/handlers/agent.js +12 -1
- package/dist/bot/handlers/context.js +15 -25
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +15 -2
- package/dist/bot/handlers/permission.js +81 -12
- package/dist/bot/handlers/question.js +97 -9
- package/dist/bot/handlers/variant.js +12 -1
- package/dist/bot/index.js +92 -16
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/config.js +31 -0
- package/dist/i18n/en.js +23 -4
- package/dist/i18n/ru.js +23 -4
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/guard.js +87 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/permission/manager.js +60 -38
- package/dist/pinned/manager.js +7 -5
- package/dist/question/manager.js +33 -0
- package/dist/rename/manager.js +3 -0
- package/dist/settings/manager.js +6 -1
- package/dist/summary/aggregator.js +87 -6
- package/dist/summary/formatter.js +91 -15
- package/dist/summary/tool-message-batcher.js +182 -0
- package/package.json +1 -1
package/dist/bot/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { SocksProxyAgent } from "socks-proxy-agent";
|
|
|
6
6
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
7
7
|
import { config } from "../config.js";
|
|
8
8
|
import { authMiddleware } from "./middleware/auth.js";
|
|
9
|
+
import { interactionGuardMiddleware } from "./middleware/interaction-guard.js";
|
|
10
|
+
import { unknownCommandMiddleware } from "./middleware/unknown-command.js";
|
|
9
11
|
import { BOT_COMMANDS } from "./commands/definitions.js";
|
|
10
12
|
import { startCommand } from "./commands/start.js";
|
|
11
13
|
import { helpCommand } from "./commands/help.js";
|
|
@@ -25,13 +27,16 @@ import { handlePermissionCallback, showPermissionRequest } from "./handlers/perm
|
|
|
25
27
|
import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
|
|
26
28
|
import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
|
|
27
29
|
import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
|
|
28
|
-
import { handleContextButtonPress, handleCompactConfirm
|
|
30
|
+
import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
|
|
31
|
+
import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
|
|
29
32
|
import { questionManager } from "../question/manager.js";
|
|
30
|
-
import {
|
|
33
|
+
import { interactionManager } from "../interaction/manager.js";
|
|
34
|
+
import { clearAllInteractionState } from "../interaction/cleanup.js";
|
|
31
35
|
import { keyboardManager } from "../keyboard/manager.js";
|
|
32
36
|
import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
|
|
33
37
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
34
38
|
import { formatSummary, formatToolInfo } from "../summary/formatter.js";
|
|
39
|
+
import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
|
|
35
40
|
import { opencodeClient } from "../opencode/client.js";
|
|
36
41
|
import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
|
|
37
42
|
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
@@ -48,6 +53,21 @@ import { t } from "../i18n/index.js";
|
|
|
48
53
|
let botInstance = null;
|
|
49
54
|
let chatIdInstance = null;
|
|
50
55
|
let commandsInitialized = false;
|
|
56
|
+
const toolMessageBatcher = new ToolMessageBatcher({
|
|
57
|
+
intervalSeconds: 5,
|
|
58
|
+
sendMessage: async (sessionId, text) => {
|
|
59
|
+
if (!botInstance || !chatIdInstance) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const currentSession = getCurrentSession();
|
|
63
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await botInstance.api.sendMessage(chatIdInstance, text, {
|
|
67
|
+
disable_notification: true,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
});
|
|
51
71
|
async function ensureCommandsInitialized(ctx, next) {
|
|
52
72
|
if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
|
|
53
73
|
await next();
|
|
@@ -78,6 +98,10 @@ async function ensureEventSubscription(directory) {
|
|
|
78
98
|
logger.error("No directory found for event subscription");
|
|
79
99
|
return;
|
|
80
100
|
}
|
|
101
|
+
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
102
|
+
summaryAggregator.setOnCleared(() => {
|
|
103
|
+
toolMessageBatcher.clearAll("summary_aggregator_clear");
|
|
104
|
+
});
|
|
81
105
|
summaryAggregator.setOnComplete(async (sessionId, messageText) => {
|
|
82
106
|
if (!botInstance || !chatIdInstance) {
|
|
83
107
|
logger.error("Bot or chat ID not available for sending message");
|
|
@@ -87,6 +111,7 @@ async function ensureEventSubscription(directory) {
|
|
|
87
111
|
if (currentSession?.id !== sessionId) {
|
|
88
112
|
return;
|
|
89
113
|
}
|
|
114
|
+
await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
|
|
90
115
|
try {
|
|
91
116
|
const parts = formatSummary(messageText);
|
|
92
117
|
logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
|
|
@@ -117,19 +142,21 @@ async function ensureEventSubscription(directory) {
|
|
|
117
142
|
}
|
|
118
143
|
});
|
|
119
144
|
summaryAggregator.setOnTool(async (toolInfo) => {
|
|
145
|
+
if (config.bot.hideToolCallMessages) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
120
148
|
if (!botInstance || !chatIdInstance) {
|
|
121
149
|
logger.error("Bot or chat ID not available for sending tool notification");
|
|
122
150
|
return;
|
|
123
151
|
}
|
|
124
152
|
const currentSession = getCurrentSession();
|
|
125
|
-
|
|
126
|
-
if (!currentSession || currentSession.id !== sessionId) {
|
|
153
|
+
if (!currentSession || currentSession.id !== toolInfo.sessionId) {
|
|
127
154
|
return;
|
|
128
155
|
}
|
|
129
156
|
try {
|
|
130
157
|
const message = formatToolInfo(toolInfo);
|
|
131
158
|
if (message) {
|
|
132
|
-
|
|
159
|
+
toolMessageBatcher.enqueue(toolInfo.sessionId, message);
|
|
133
160
|
}
|
|
134
161
|
}
|
|
135
162
|
catch (err) {
|
|
@@ -155,6 +182,7 @@ async function ensureEventSubscription(directory) {
|
|
|
155
182
|
await fs.writeFile(tempFilePath, fileData.buffer);
|
|
156
183
|
await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
|
|
157
184
|
caption: fileData.caption,
|
|
185
|
+
disable_notification: true,
|
|
158
186
|
});
|
|
159
187
|
await fs.unlink(tempFilePath);
|
|
160
188
|
logger.debug(`[Bot] Temporary file deleted: ${fileData.filename}`);
|
|
@@ -168,6 +196,18 @@ async function ensureEventSubscription(directory) {
|
|
|
168
196
|
logger.error("Bot or chat ID not available for showing questions");
|
|
169
197
|
return;
|
|
170
198
|
}
|
|
199
|
+
const currentSession = getCurrentSession();
|
|
200
|
+
if (currentSession) {
|
|
201
|
+
await toolMessageBatcher.flushSession(currentSession.id, "question_asked");
|
|
202
|
+
}
|
|
203
|
+
if (questionManager.isActive()) {
|
|
204
|
+
logger.warn("[Bot] Replacing active poll with a new one");
|
|
205
|
+
const previousMessageIds = questionManager.getMessageIds();
|
|
206
|
+
for (const messageId of previousMessageIds) {
|
|
207
|
+
await botInstance.api.deleteMessage(chatIdInstance, messageId).catch(() => { });
|
|
208
|
+
}
|
|
209
|
+
clearAllInteractionState("question_replaced_by_new_poll");
|
|
210
|
+
}
|
|
171
211
|
logger.info(`[Bot] Received ${questions.length} questions from agent, requestID=${requestID}`);
|
|
172
212
|
questionManager.startQuestions(questions, requestID);
|
|
173
213
|
await showCurrentQuestion(botInstance.api, chatIdInstance);
|
|
@@ -183,24 +223,30 @@ async function ensureEventSubscription(directory) {
|
|
|
183
223
|
});
|
|
184
224
|
}
|
|
185
225
|
}
|
|
186
|
-
|
|
226
|
+
clearAllInteractionState("question_error");
|
|
187
227
|
});
|
|
188
228
|
summaryAggregator.setOnPermission(async (request) => {
|
|
189
229
|
if (!botInstance || !chatIdInstance) {
|
|
190
230
|
logger.error("Bot or chat ID not available for showing permission request");
|
|
191
231
|
return;
|
|
192
232
|
}
|
|
233
|
+
await toolMessageBatcher.flushSession(request.sessionID, "permission_asked");
|
|
193
234
|
logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
|
|
194
235
|
await showPermissionRequest(botInstance.api, chatIdInstance, request);
|
|
195
236
|
});
|
|
196
|
-
summaryAggregator.setOnThinking(async () => {
|
|
237
|
+
summaryAggregator.setOnThinking(async (sessionId) => {
|
|
238
|
+
if (config.bot.hideThinkingMessages) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
197
241
|
if (!botInstance || !chatIdInstance) {
|
|
198
242
|
return;
|
|
199
243
|
}
|
|
244
|
+
const currentSession = getCurrentSession();
|
|
245
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
200
248
|
logger.debug("[Bot] Agent started thinking");
|
|
201
|
-
|
|
202
|
-
logger.error("[Bot] Failed to send thinking message:", err);
|
|
203
|
-
});
|
|
249
|
+
toolMessageBatcher.enqueue(sessionId, t("bot.thinking"));
|
|
204
250
|
});
|
|
205
251
|
summaryAggregator.setOnTokens(async (tokens) => {
|
|
206
252
|
if (!pinnedMessageManager.isInitialized()) {
|
|
@@ -298,8 +344,7 @@ async function isSessionBusy(sessionId, directory) {
|
|
|
298
344
|
async function resetMismatchedSessionContext() {
|
|
299
345
|
stopEventListening();
|
|
300
346
|
summaryAggregator.clear();
|
|
301
|
-
|
|
302
|
-
permissionManager.clear();
|
|
347
|
+
clearAllInteractionState("session_mismatch_reset");
|
|
303
348
|
clearSession();
|
|
304
349
|
keyboardManager.clearContext();
|
|
305
350
|
if (!pinnedMessageManager.isInitialized()) {
|
|
@@ -313,6 +358,9 @@ async function resetMismatchedSessionContext() {
|
|
|
313
358
|
}
|
|
314
359
|
}
|
|
315
360
|
export function createBot() {
|
|
361
|
+
clearAllInteractionState("bot_startup");
|
|
362
|
+
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
363
|
+
logger.info(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
|
|
316
364
|
const botOptions = {};
|
|
317
365
|
if (config.telegram.proxyUrl) {
|
|
318
366
|
const proxyUrl = config.telegram.proxyUrl;
|
|
@@ -365,6 +413,16 @@ export function createBot() {
|
|
|
365
413
|
});
|
|
366
414
|
bot.use(authMiddleware);
|
|
367
415
|
bot.use(ensureCommandsInitialized);
|
|
416
|
+
bot.use(interactionGuardMiddleware);
|
|
417
|
+
const blockMenuWhileInteractionActive = async (ctx) => {
|
|
418
|
+
const activeInteraction = interactionManager.getSnapshot();
|
|
419
|
+
if (!activeInteraction) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
logger.debug(`[Bot] Blocking menu open while interaction active: kind=${activeInteraction.kind}, expectedInput=${activeInteraction.expectedInput}`);
|
|
423
|
+
await ctx.reply(t("interaction.blocked.finish_current"));
|
|
424
|
+
return true;
|
|
425
|
+
};
|
|
368
426
|
bot.command("start", startCommand);
|
|
369
427
|
bot.command("help", helpCommand);
|
|
370
428
|
bot.command("status", statusCommand);
|
|
@@ -377,10 +435,12 @@ export function createBot() {
|
|
|
377
435
|
bot.command("model", handleModelCommand);
|
|
378
436
|
bot.command("stop", stopCommand);
|
|
379
437
|
bot.command("rename", renameCommand);
|
|
438
|
+
bot.on("message:text", unknownCommandMiddleware);
|
|
380
439
|
bot.on("callback_query:data", async (ctx) => {
|
|
381
440
|
logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
|
|
382
441
|
logger.debug(`[Bot] Callback context: from=${ctx.from?.id}, chat=${ctx.chat?.id}`);
|
|
383
442
|
try {
|
|
443
|
+
const handledInlineCancel = await handleInlineMenuCancel(ctx);
|
|
384
444
|
const handledSession = await handleSessionSelect(ctx);
|
|
385
445
|
const handledProject = await handleProjectSelect(ctx);
|
|
386
446
|
const handledQuestion = await handleQuestionCallback(ctx);
|
|
@@ -389,10 +449,10 @@ export function createBot() {
|
|
|
389
449
|
const handledModel = await handleModelSelect(ctx);
|
|
390
450
|
const handledVariant = await handleVariantSelect(ctx);
|
|
391
451
|
const handledCompactConfirm = await handleCompactConfirm(ctx);
|
|
392
|
-
const handledCompactCancel = await handleCompactCancel(ctx);
|
|
393
452
|
const handledRenameCancel = await handleRenameCancel(ctx);
|
|
394
|
-
logger.debug(`[Bot] Callback handled: session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant},
|
|
395
|
-
if (!
|
|
453
|
+
logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, rename=${handledRenameCancel}`);
|
|
454
|
+
if (!handledInlineCancel &&
|
|
455
|
+
!handledSession &&
|
|
396
456
|
!handledProject &&
|
|
397
457
|
!handledQuestion &&
|
|
398
458
|
!handledPermission &&
|
|
@@ -400,7 +460,6 @@ export function createBot() {
|
|
|
400
460
|
!handledModel &&
|
|
401
461
|
!handledVariant &&
|
|
402
462
|
!handledCompactConfirm &&
|
|
403
|
-
!handledCompactCancel &&
|
|
404
463
|
!handledRenameCancel) {
|
|
405
464
|
logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
|
|
406
465
|
await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
|
|
@@ -408,6 +467,7 @@ export function createBot() {
|
|
|
408
467
|
}
|
|
409
468
|
catch (err) {
|
|
410
469
|
logger.error("[Bot] Error handling callback:", err);
|
|
470
|
+
clearAllInteractionState("callback_handler_error");
|
|
411
471
|
await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
|
|
412
472
|
}
|
|
413
473
|
});
|
|
@@ -415,6 +475,9 @@ export function createBot() {
|
|
|
415
475
|
bot.hears(/^(📋|🛠️|💬|🔍|📝|📄|📦|🤖) \w+ Mode$/, async (ctx) => {
|
|
416
476
|
logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
|
|
417
477
|
try {
|
|
478
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
418
481
|
await showAgentSelectionMenu(ctx);
|
|
419
482
|
}
|
|
420
483
|
catch (err) {
|
|
@@ -427,6 +490,9 @@ export function createBot() {
|
|
|
427
490
|
bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
428
491
|
logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
|
|
429
492
|
try {
|
|
493
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
430
496
|
await showModelSelectionMenu(ctx);
|
|
431
497
|
}
|
|
432
498
|
catch (err) {
|
|
@@ -438,6 +504,9 @@ export function createBot() {
|
|
|
438
504
|
bot.hears(/^📊(?:\s|$)/, async (ctx) => {
|
|
439
505
|
logger.debug(`[Bot] Context button pressed: ${ctx.message?.text}`);
|
|
440
506
|
try {
|
|
507
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
441
510
|
await handleContextButtonPress(ctx);
|
|
442
511
|
}
|
|
443
512
|
catch (err) {
|
|
@@ -450,6 +519,9 @@ export function createBot() {
|
|
|
450
519
|
bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
451
520
|
logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
|
|
452
521
|
try {
|
|
522
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
453
525
|
await showVariantSelectionMenu(ctx);
|
|
454
526
|
}
|
|
455
527
|
catch (err) {
|
|
@@ -628,12 +700,16 @@ export function createBot() {
|
|
|
628
700
|
}
|
|
629
701
|
catch (err) {
|
|
630
702
|
logger.error("Error in prompt handler:", err);
|
|
703
|
+
if (interactionManager.getSnapshot()) {
|
|
704
|
+
clearAllInteractionState("message_handler_error");
|
|
705
|
+
}
|
|
631
706
|
await ctx.reply(t("error.generic"));
|
|
632
707
|
}
|
|
633
708
|
logger.debug("[Bot] message:text handler completed (prompt sent in background)");
|
|
634
709
|
});
|
|
635
710
|
bot.catch((err) => {
|
|
636
711
|
logger.error("[Bot] Unhandled error in bot:", err);
|
|
712
|
+
clearAllInteractionState("bot_unhandled_error");
|
|
637
713
|
if (err.ctx) {
|
|
638
714
|
logger.error("[Bot] Error context - update type:", err.ctx.update ? Object.keys(err.ctx.update) : "unknown");
|
|
639
715
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolveInteractionGuardDecision } from "../../interaction/guard.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
function getInteractionBlockedMessage(reason, interactionKind) {
|
|
5
|
+
if (interactionKind === "permission") {
|
|
6
|
+
switch (reason) {
|
|
7
|
+
case "command_not_allowed":
|
|
8
|
+
return t("permission.blocked.command_not_allowed");
|
|
9
|
+
case "expected_callback":
|
|
10
|
+
case "expected_command":
|
|
11
|
+
case "expected_text":
|
|
12
|
+
default:
|
|
13
|
+
return t("permission.blocked.expected_reply");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (interactionKind === "inline") {
|
|
17
|
+
switch (reason) {
|
|
18
|
+
case "command_not_allowed":
|
|
19
|
+
return t("inline.blocked.command_not_allowed");
|
|
20
|
+
case "expected_callback":
|
|
21
|
+
case "expected_command":
|
|
22
|
+
case "expected_text":
|
|
23
|
+
default:
|
|
24
|
+
return t("inline.blocked.expected_choice");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (interactionKind === "question") {
|
|
28
|
+
switch (reason) {
|
|
29
|
+
case "command_not_allowed":
|
|
30
|
+
return t("question.blocked.command_not_allowed");
|
|
31
|
+
case "expected_callback":
|
|
32
|
+
case "expected_command":
|
|
33
|
+
case "expected_text":
|
|
34
|
+
default:
|
|
35
|
+
return t("question.blocked.expected_answer");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (interactionKind === "rename") {
|
|
39
|
+
switch (reason) {
|
|
40
|
+
case "command_not_allowed":
|
|
41
|
+
return t("rename.blocked.command_not_allowed");
|
|
42
|
+
case "expected_callback":
|
|
43
|
+
case "expected_command":
|
|
44
|
+
case "expected_text":
|
|
45
|
+
default:
|
|
46
|
+
return t("rename.blocked.expected_name");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
switch (reason) {
|
|
50
|
+
case "expired":
|
|
51
|
+
return t("interaction.blocked.expired");
|
|
52
|
+
case "expected_callback":
|
|
53
|
+
return t("interaction.blocked.expected_callback");
|
|
54
|
+
case "expected_command":
|
|
55
|
+
return t("interaction.blocked.expected_command");
|
|
56
|
+
case "command_not_allowed":
|
|
57
|
+
return t("interaction.blocked.command_not_allowed");
|
|
58
|
+
case "expected_text":
|
|
59
|
+
default:
|
|
60
|
+
return t("interaction.blocked.expected_text");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function interactionGuardMiddleware(ctx, next) {
|
|
64
|
+
const decision = resolveInteractionGuardDecision(ctx);
|
|
65
|
+
if (decision.allow) {
|
|
66
|
+
await next();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const message = getInteractionBlockedMessage(decision.reason, decision.state?.kind);
|
|
70
|
+
logger.debug(`[InteractionGuard] Blocked input: interactionKind=${decision.state?.kind || "none"}, inputType=${decision.inputType}, reason=${decision.reason || "unknown"}, command=${decision.command || "-"}`);
|
|
71
|
+
if (ctx.callbackQuery) {
|
|
72
|
+
await ctx.answerCallbackQuery({ text: message }).catch(() => { });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (ctx.chat) {
|
|
76
|
+
await ctx.reply(message).catch((err) => {
|
|
77
|
+
logger.error("[InteractionGuard] Failed to send blocked input message:", err);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { extractCommandName, isKnownCommand } from "../utils/commands.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
export async function unknownCommandMiddleware(ctx, next) {
|
|
5
|
+
const text = ctx.message?.text;
|
|
6
|
+
if (!text) {
|
|
7
|
+
await next();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const commandName = extractCommandName(text);
|
|
11
|
+
if (!commandName) {
|
|
12
|
+
await next();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (isKnownCommand(commandName)) {
|
|
16
|
+
await next();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const commandToken = text.trim().split(/\s+/)[0];
|
|
20
|
+
logger.debug(`[Bot] Unknown slash command received: ${commandToken}`);
|
|
21
|
+
await ctx.reply(t("bot.unknown_command", { command: commandToken }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BOT_COMMANDS } from "../commands/definitions.js";
|
|
2
|
+
const KNOWN_COMMANDS = new Set(["start", ...BOT_COMMANDS.map((item) => item.command)]);
|
|
3
|
+
export function extractCommandName(text) {
|
|
4
|
+
const trimmed = text.trim();
|
|
5
|
+
if (!trimmed.startsWith("/")) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const token = trimmed.split(/\s+/)[0];
|
|
9
|
+
const withoutSlash = token.slice(1);
|
|
10
|
+
if (!withoutSlash) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const withoutMention = withoutSlash.split("@")[0].toLowerCase();
|
|
14
|
+
if (!withoutMention) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return withoutMention;
|
|
18
|
+
}
|
|
19
|
+
export function isKnownCommand(commandName) {
|
|
20
|
+
return KNOWN_COMMANDS.has(commandName);
|
|
21
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -20,6 +20,20 @@ function getOptionalPositiveIntEnvVar(key, defaultValue) {
|
|
|
20
20
|
}
|
|
21
21
|
return parsedValue;
|
|
22
22
|
}
|
|
23
|
+
function getOptionalNonNegativeIntEnvVarFromKeys(keys, defaultValue) {
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
const value = getEnvVar(key, false);
|
|
26
|
+
if (!value) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const parsedValue = Number.parseInt(value, 10);
|
|
30
|
+
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
|
31
|
+
return defaultValue;
|
|
32
|
+
}
|
|
33
|
+
return parsedValue;
|
|
34
|
+
}
|
|
35
|
+
return defaultValue;
|
|
36
|
+
}
|
|
23
37
|
function getOptionalLocaleEnvVar(key, defaultValue) {
|
|
24
38
|
const value = getEnvVar(key, false);
|
|
25
39
|
if (!value) {
|
|
@@ -34,6 +48,20 @@ function getOptionalLocaleEnvVar(key, defaultValue) {
|
|
|
34
48
|
}
|
|
35
49
|
return defaultValue;
|
|
36
50
|
}
|
|
51
|
+
function getOptionalBooleanEnvVar(key, defaultValue) {
|
|
52
|
+
const value = getEnvVar(key, false);
|
|
53
|
+
if (!value) {
|
|
54
|
+
return defaultValue;
|
|
55
|
+
}
|
|
56
|
+
const normalized = value.trim().toLowerCase();
|
|
57
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return defaultValue;
|
|
64
|
+
}
|
|
37
65
|
export const config = {
|
|
38
66
|
telegram: {
|
|
39
67
|
token: getEnvVar("TELEGRAM_BOT_TOKEN"),
|
|
@@ -55,6 +83,9 @@ export const config = {
|
|
|
55
83
|
bot: {
|
|
56
84
|
sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
|
|
57
85
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
86
|
+
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
87
|
+
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
88
|
+
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
|
|
58
89
|
},
|
|
59
90
|
files: {
|
|
60
91
|
maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
|
package/dist/i18n/en.js
CHANGED
|
@@ -16,6 +16,19 @@ export const en = {
|
|
|
16
16
|
"error.load_variants": "❌ Failed to load variants list",
|
|
17
17
|
"error.context_button": "❌ Failed to process context button",
|
|
18
18
|
"error.generic": "🔴 Something went wrong.",
|
|
19
|
+
"interaction.blocked.expired": "⚠️ This interaction has expired. Please start it again.",
|
|
20
|
+
"interaction.blocked.expected_callback": "⚠️ Please use the inline buttons for this step or tap Cancel.",
|
|
21
|
+
"interaction.blocked.expected_text": "⚠️ Please send a text message for this step.",
|
|
22
|
+
"interaction.blocked.expected_command": "⚠️ Please send a command for this step.",
|
|
23
|
+
"interaction.blocked.command_not_allowed": "⚠️ This command is not available in the current step.",
|
|
24
|
+
"interaction.blocked.finish_current": "⚠️ Finish the current interaction first (answer or cancel), then open another menu.",
|
|
25
|
+
"inline.blocked.expected_choice": "⚠️ Choose an option using the inline buttons or tap Cancel.",
|
|
26
|
+
"inline.blocked.command_not_allowed": "⚠️ This command is not available while inline menu is active.",
|
|
27
|
+
"question.blocked.expected_answer": "⚠️ Answer the current question using buttons, Custom answer, or Cancel.",
|
|
28
|
+
"question.blocked.command_not_allowed": "⚠️ This command is not available until current question flow is completed.",
|
|
29
|
+
"inline.button.cancel": "❌ Cancel",
|
|
30
|
+
"inline.inactive_callback": "This menu is inactive",
|
|
31
|
+
"inline.cancelled_callback": "Cancelled",
|
|
19
32
|
"common.unknown": "unknown",
|
|
20
33
|
"common.unknown_error": "unknown error",
|
|
21
34
|
"start.welcome": "👋 Welcome to OpenCode Telegram Bot!\n\nUse commands:\n/projects — select project\n/sessions — session list\n/new — new session\n/agent — switch mode\n/model — select model\n/status — status\n/help — help",
|
|
@@ -29,6 +42,7 @@ export const en = {
|
|
|
29
42
|
"bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
|
|
30
43
|
"bot.prompt_send_error_detailed": "🔴 Failed to send request.\n\nDetails: {details}",
|
|
31
44
|
"bot.prompt_send_error": "🔴 An error occurred while sending request to OpenCode.",
|
|
45
|
+
"bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
|
|
32
46
|
"status.header_running": "🟢 **OpenCode Server is running**",
|
|
33
47
|
"status.health.healthy": "Healthy",
|
|
34
48
|
"status.health.unhealthy": "Unhealthy",
|
|
@@ -111,7 +125,6 @@ export const en = {
|
|
|
111
125
|
"variant.menu.current": "Current variant: {name}\n\nSelect variant:",
|
|
112
126
|
"variant.menu.error": "🔴 Failed to get variants list",
|
|
113
127
|
"context.button.confirm": "✅ Yes, compact context",
|
|
114
|
-
"context.button.cancel": "❌ Cancel",
|
|
115
128
|
"context.no_active_session": "⚠️ No active session. Create a session with /new",
|
|
116
129
|
"context.confirm_text": '📊 Context compaction for session "{title}"\n\nThis will reduce context usage by removing old messages from history. Current task will not be interrupted.\n\nContinue?',
|
|
117
130
|
"context.callback_session_not_found": "Session not found",
|
|
@@ -119,7 +132,6 @@ export const en = {
|
|
|
119
132
|
"context.progress": "⏳ Compacting context...",
|
|
120
133
|
"context.error": "❌ Context compaction failed",
|
|
121
134
|
"context.success": "✅ Context compacted successfully",
|
|
122
|
-
"context.callback_cancelled": "Cancelled",
|
|
123
135
|
"permission.inactive_callback": "Permission request is inactive",
|
|
124
136
|
"permission.processing_error_callback": "Processing error",
|
|
125
137
|
"permission.no_active_request_callback": "Error: no active request",
|
|
@@ -127,9 +139,11 @@ export const en = {
|
|
|
127
139
|
"permission.reply.always": "Always allowed",
|
|
128
140
|
"permission.reply.reject": "Rejected",
|
|
129
141
|
"permission.send_reply_error": "❌ Failed to send permission reply",
|
|
142
|
+
"permission.blocked.expected_reply": "⚠️ Please answer the permission request first using the buttons above.",
|
|
143
|
+
"permission.blocked.command_not_allowed": "⚠️ This command is not available until you answer the permission request.",
|
|
130
144
|
"permission.header": "{emoji} **Permission request: {name}**\n\n",
|
|
131
|
-
"permission.button.allow": "✅ Allow",
|
|
132
|
-
"permission.button.always": "🔓
|
|
145
|
+
"permission.button.allow": "✅ Allow once",
|
|
146
|
+
"permission.button.always": "🔓 Allow always",
|
|
133
147
|
"permission.button.reject": "❌ Reject",
|
|
134
148
|
"permission.name.bash": "Bash",
|
|
135
149
|
"permission.name.edit": "Edit",
|
|
@@ -156,6 +170,7 @@ export const en = {
|
|
|
156
170
|
"question.button.submit": "✅ Done",
|
|
157
171
|
"question.button.custom": "🔤 Custom answer",
|
|
158
172
|
"question.button.cancel": "❌ Cancel",
|
|
173
|
+
"question.use_custom_button_first": '⚠️ To send text, tap "Custom answer" for the current question first.',
|
|
159
174
|
"question.summary.title": "✅ Poll completed!\n\n",
|
|
160
175
|
"question.summary.question": "Question {index}:\n{question}\n\n",
|
|
161
176
|
"question.summary.answer": "Answer:\n{answer}\n\n",
|
|
@@ -193,6 +208,10 @@ export const en = {
|
|
|
193
208
|
"rename.success": "✅ Session renamed to: {title}",
|
|
194
209
|
"rename.error": "🔴 Failed to rename session.",
|
|
195
210
|
"rename.cancelled": "❌ Rename cancelled.",
|
|
211
|
+
"rename.inactive_callback": "Rename request is inactive",
|
|
212
|
+
"rename.inactive": "⚠️ Rename request is not active. Run /rename again.",
|
|
213
|
+
"rename.blocked.expected_name": "⚠️ Enter a new session name as text or tap Cancel in rename message.",
|
|
214
|
+
"rename.blocked.command_not_allowed": "⚠️ This command is not available while rename is waiting for a new name.",
|
|
196
215
|
"rename.button.cancel": "❌ Cancel",
|
|
197
216
|
"cmd.description.rename": "Rename current session",
|
|
198
217
|
"cli.usage": "Usage:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotes:\n - No command defaults to `start`\n - `--mode` is currently supported for `start` only",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -16,6 +16,19 @@ export const ru = {
|
|
|
16
16
|
"error.load_variants": "❌ Ошибка при загрузке списка вариантов",
|
|
17
17
|
"error.context_button": "❌ Ошибка при обработке кнопки контекста",
|
|
18
18
|
"error.generic": "🔴 Произошла ошибка.",
|
|
19
|
+
"interaction.blocked.expired": "⚠️ Текущая интеракция устарела. Запустите ее снова.",
|
|
20
|
+
"interaction.blocked.expected_callback": "⚠️ Для этого шага используйте inline-кнопки или нажмите Отмена.",
|
|
21
|
+
"interaction.blocked.expected_text": "⚠️ Для этого шага отправьте текстовое сообщение.",
|
|
22
|
+
"interaction.blocked.expected_command": "⚠️ Для этого шага отправьте команду.",
|
|
23
|
+
"interaction.blocked.command_not_allowed": "⚠️ Эта команда недоступна на текущем шаге.",
|
|
24
|
+
"interaction.blocked.finish_current": "⚠️ Сначала завершите текущую интеракцию (ответьте или отмените), затем откройте другое меню.",
|
|
25
|
+
"inline.blocked.expected_choice": "⚠️ Выберите вариант через inline-кнопки или нажмите Отмена.",
|
|
26
|
+
"inline.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока активно inline-меню.",
|
|
27
|
+
"question.blocked.expected_answer": "⚠️ Ответьте на текущий вопрос кнопками, через Свой ответ, или нажмите Отмена.",
|
|
28
|
+
"question.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока не завершен текущий опрос.",
|
|
29
|
+
"inline.button.cancel": "❌ Отмена",
|
|
30
|
+
"inline.inactive_callback": "Это меню уже неактивно",
|
|
31
|
+
"inline.cancelled_callback": "Отменено",
|
|
19
32
|
"common.unknown": "неизвестна",
|
|
20
33
|
"common.unknown_error": "неизвестная ошибка",
|
|
21
34
|
"start.welcome": "👋 Добро пожаловать в OpenCode Telegram Bot!\n\nИспользуйте команды:\n/projects — выбрать проект\n/sessions — список сессий\n/new — новая сессия\n/agent — сменить режим\n/model — выбрать модель\n/status — статус\n/help — справка",
|
|
@@ -29,6 +42,7 @@ export const ru = {
|
|
|
29
42
|
"bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
|
|
30
43
|
"bot.prompt_send_error_detailed": "🔴 Ошибка при отправке запроса.\n\nДетали: {details}",
|
|
31
44
|
"bot.prompt_send_error": "🔴 Произошла ошибка при отправке запроса в OpenCode.",
|
|
45
|
+
"bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
|
|
32
46
|
"status.header_running": "🟢 **OpenCode Server запущен**",
|
|
33
47
|
"status.health.healthy": "Healthy",
|
|
34
48
|
"status.health.unhealthy": "Unhealthy",
|
|
@@ -111,7 +125,6 @@ export const ru = {
|
|
|
111
125
|
"variant.menu.current": "Текущий variant: {name}\n\nВыберите variant:",
|
|
112
126
|
"variant.menu.error": "🔴 Не удалось получить список вариантов",
|
|
113
127
|
"context.button.confirm": "✅ Да, сжать контекст",
|
|
114
|
-
"context.button.cancel": "❌ Отмена",
|
|
115
128
|
"context.no_active_session": "⚠️ Нет активной сессии. Создайте сессию командой /new",
|
|
116
129
|
"context.confirm_text": '📊 Сжатие контекста для сессии "{title}"\n\nЭто уменьшит использование контекста, удалив старые сообщения из истории. Текущая задача не будет прервана.\n\nПродолжить?',
|
|
117
130
|
"context.callback_session_not_found": "Сессия не найдена",
|
|
@@ -119,7 +132,6 @@ export const ru = {
|
|
|
119
132
|
"context.progress": "⏳ Сжимаю контекст...",
|
|
120
133
|
"context.error": "❌ Ошибка при сжатии контекста",
|
|
121
134
|
"context.success": "✅ Контекст успешно сжат",
|
|
122
|
-
"context.callback_cancelled": "Отменено",
|
|
123
135
|
"permission.inactive_callback": "Запрос разрешения неактивен",
|
|
124
136
|
"permission.processing_error_callback": "Ошибка при обработке",
|
|
125
137
|
"permission.no_active_request_callback": "Ошибка: нет активного запроса",
|
|
@@ -127,9 +139,11 @@ export const ru = {
|
|
|
127
139
|
"permission.reply.always": "Разрешено всегда",
|
|
128
140
|
"permission.reply.reject": "Отклонено",
|
|
129
141
|
"permission.send_reply_error": "❌ Не удалось отправить ответ на запрос разрешения",
|
|
142
|
+
"permission.blocked.expected_reply": "⚠️ Сначала ответьте на запрос разрешения кнопками выше.",
|
|
143
|
+
"permission.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока вы не ответите на запрос разрешения.",
|
|
130
144
|
"permission.header": "{emoji} **Запрос разрешения: {name}**\n\n",
|
|
131
|
-
"permission.button.allow": "✅ Разрешить",
|
|
132
|
-
"permission.button.always": "🔓
|
|
145
|
+
"permission.button.allow": "✅ Разрешить один раз",
|
|
146
|
+
"permission.button.always": "🔓 Разрешить всегда",
|
|
133
147
|
"permission.button.reject": "❌ Отклонить",
|
|
134
148
|
"permission.name.bash": "Bash",
|
|
135
149
|
"permission.name.edit": "Edit",
|
|
@@ -156,6 +170,7 @@ export const ru = {
|
|
|
156
170
|
"question.button.submit": "✅ Готово",
|
|
157
171
|
"question.button.custom": "🔤 Свой ответ",
|
|
158
172
|
"question.button.cancel": "❌ Отмена",
|
|
173
|
+
"question.use_custom_button_first": '⚠️ Чтобы отправить текст, сначала нажмите кнопку "Свой ответ" для текущего вопроса.',
|
|
159
174
|
"question.summary.title": "✅ Опрос завершен!\n\n",
|
|
160
175
|
"question.summary.question": "Вопрос {index}:\n{question}\n\n",
|
|
161
176
|
"question.summary.answer": "Ответ:\n{answer}\n\n",
|
|
@@ -193,6 +208,10 @@ export const ru = {
|
|
|
193
208
|
"rename.success": "✅ Сессия переименована в: {title}",
|
|
194
209
|
"rename.error": "🔴 Не удалось переименовать сессию.",
|
|
195
210
|
"rename.cancelled": "❌ Переименование отменено.",
|
|
211
|
+
"rename.inactive_callback": "Запрос переименования неактивен",
|
|
212
|
+
"rename.inactive": "⚠️ Запрос переименования неактивен. Выполните /rename снова.",
|
|
213
|
+
"rename.blocked.expected_name": "⚠️ Введите новое название текстом или нажмите Отмена в сообщении переименования.",
|
|
214
|
+
"rename.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока ожидается новое название сессии.",
|
|
196
215
|
"rename.button.cancel": "❌ Отмена",
|
|
197
216
|
"cmd.description.rename": "Переименовать текущую сессию",
|
|
198
217
|
"cli.usage": "Использование:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nЗаметки:\n - Без команды по умолчанию используется `start`\n - `--mode` сейчас поддерживается только для `start`",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { permissionManager } from "../permission/manager.js";
|
|
2
|
+
import { questionManager } from "../question/manager.js";
|
|
3
|
+
import { renameManager } from "../rename/manager.js";
|
|
4
|
+
import { interactionManager } from "./manager.js";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
export function clearAllInteractionState(reason) {
|
|
7
|
+
const questionActive = questionManager.isActive();
|
|
8
|
+
const permissionActive = permissionManager.isActive();
|
|
9
|
+
const renameActive = renameManager.isWaitingForName();
|
|
10
|
+
const interactionSnapshot = interactionManager.getSnapshot();
|
|
11
|
+
questionManager.clear();
|
|
12
|
+
permissionManager.clear();
|
|
13
|
+
renameManager.clear();
|
|
14
|
+
interactionManager.clear(reason);
|
|
15
|
+
const hasAnyActiveState = questionActive || permissionActive || renameActive || interactionSnapshot !== null;
|
|
16
|
+
const message = `[InteractionCleanup] Cleared state: reason=${reason}, ` +
|
|
17
|
+
`questionActive=${questionActive}, permissionActive=${permissionActive}, ` +
|
|
18
|
+
`renameActive=${renameActive}, interactionKind=${interactionSnapshot?.kind || "none"}`;
|
|
19
|
+
if (hasAnyActiveState) {
|
|
20
|
+
logger.info(message);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
logger.debug(message);
|
|
24
|
+
}
|