@grinev/opencode-telegram-bot 0.3.0 → 0.5.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/README.md +10 -1
- package/dist/bot/commands/definitions.js +8 -4
- package/dist/bot/commands/help.js +7 -1
- package/dist/bot/commands/new.js +2 -0
- package/dist/bot/commands/projects.js +18 -9
- package/dist/bot/commands/rename.js +139 -0
- package/dist/bot/commands/sessions.js +13 -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 +50 -5
- package/dist/bot/handlers/question.js +97 -9
- package/dist/bot/handlers/variant.js +12 -1
- package/dist/bot/index.js +57 -9
- 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/i18n/en.js +31 -4
- package/dist/i18n/ru.js +31 -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 +6 -0
- package/dist/question/manager.js +33 -0
- package/dist/rename/manager.js +53 -0
- package/package.json +1 -1
|
@@ -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/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",
|
|
@@ -187,6 +202,18 @@ export const en = {
|
|
|
187
202
|
"runtime.wizard.saved": "Configuration saved:\n- {envPath}\n- {settingsPath}\n",
|
|
188
203
|
"runtime.wizard.not_configured_starting": "Application is not configured yet. Starting wizard...\n",
|
|
189
204
|
"runtime.wizard.tty_required": "Interactive wizard requires a TTY terminal. Run `opencode-telegram config` in an interactive shell.",
|
|
205
|
+
"rename.no_session": "⚠️ No active session. Create or select a session first.",
|
|
206
|
+
"rename.prompt": "📝 Enter new title for session:\n\nCurrent: {title}",
|
|
207
|
+
"rename.empty_title": "⚠️ Title cannot be empty.",
|
|
208
|
+
"rename.success": "✅ Session renamed to: {title}",
|
|
209
|
+
"rename.error": "🔴 Failed to rename session.",
|
|
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.",
|
|
215
|
+
"rename.button.cancel": "❌ Cancel",
|
|
216
|
+
"cmd.description.rename": "Rename current session",
|
|
190
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",
|
|
191
218
|
"cli.placeholder.status": "Command `status` is currently a placeholder. Real status checks will be added in service layer (Phase 5).",
|
|
192
219
|
"cli.placeholder.stop": "Command `stop` is currently a placeholder. Real background process stop will be added in service layer (Phase 5).",
|
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",
|
|
@@ -187,6 +202,18 @@ export const ru = {
|
|
|
187
202
|
"runtime.wizard.saved": "Конфигурация сохранена:\n- {envPath}\n- {settingsPath}\n",
|
|
188
203
|
"runtime.wizard.not_configured_starting": "Приложение еще не сконфигурировано. Запускаю wizard...\n",
|
|
189
204
|
"runtime.wizard.tty_required": "Интерактивный wizard требует TTY-терминал. Запустите `opencode-telegram config` в интерактивной оболочке.",
|
|
205
|
+
"rename.no_session": "⚠️ Нет активной сессии. Сначала создайте или выберите сессию.",
|
|
206
|
+
"rename.prompt": "📝 Введите новое название сессии:\n\nТекущее: {title}",
|
|
207
|
+
"rename.empty_title": "⚠️ Название не может быть пустым.",
|
|
208
|
+
"rename.success": "✅ Сессия переименована в: {title}",
|
|
209
|
+
"rename.error": "🔴 Не удалось переименовать сессию.",
|
|
210
|
+
"rename.cancelled": "❌ Переименование отменено.",
|
|
211
|
+
"rename.inactive_callback": "Запрос переименования неактивен",
|
|
212
|
+
"rename.inactive": "⚠️ Запрос переименования неактивен. Выполните /rename снова.",
|
|
213
|
+
"rename.blocked.expected_name": "⚠️ Введите новое название текстом или нажмите Отмена в сообщении переименования.",
|
|
214
|
+
"rename.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока ожидается новое название сессии.",
|
|
215
|
+
"rename.button.cancel": "❌ Отмена",
|
|
216
|
+
"cmd.description.rename": "Переименовать текущую сессию",
|
|
190
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`",
|
|
191
218
|
"cli.placeholder.status": "Команда `status` пока работает как заглушка. Реальная проверка статуса появится на этапе service-слоя (Этап 5).",
|
|
192
219
|
"cli.placeholder.stop": "Команда `stop` пока работает как заглушка. Реальная остановка фонового процесса появится на этапе service-слоя (Этап 5).",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { interactionManager } from "./manager.js";
|
|
2
|
+
function normalizeIncomingCommand(text) {
|
|
3
|
+
const trimmed = text.trim();
|
|
4
|
+
if (!trimmed.startsWith("/")) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
const token = trimmed.split(/\s+/)[0];
|
|
8
|
+
const withoutMention = token.split("@")[0].toLowerCase();
|
|
9
|
+
if (withoutMention.length <= 1) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return withoutMention;
|
|
13
|
+
}
|
|
14
|
+
function classifyIncomingInput(ctx) {
|
|
15
|
+
if (ctx.callbackQuery?.data) {
|
|
16
|
+
return { inputType: "callback" };
|
|
17
|
+
}
|
|
18
|
+
const text = ctx.message?.text;
|
|
19
|
+
if (typeof text === "string") {
|
|
20
|
+
const command = normalizeIncomingCommand(text);
|
|
21
|
+
if (command) {
|
|
22
|
+
return { inputType: "command", command };
|
|
23
|
+
}
|
|
24
|
+
return { inputType: "text" };
|
|
25
|
+
}
|
|
26
|
+
return { inputType: "other" };
|
|
27
|
+
}
|
|
28
|
+
function getExpectedInputBlockReason(expectedInput) {
|
|
29
|
+
switch (expectedInput) {
|
|
30
|
+
case "callback":
|
|
31
|
+
return "expected_callback";
|
|
32
|
+
case "command":
|
|
33
|
+
return "expected_command";
|
|
34
|
+
case "text":
|
|
35
|
+
case "mixed":
|
|
36
|
+
return "expected_text";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function createAllowDecision(inputType, state, command) {
|
|
40
|
+
return {
|
|
41
|
+
allow: true,
|
|
42
|
+
inputType,
|
|
43
|
+
state,
|
|
44
|
+
command,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function createBlockDecision(inputType, state, reason, command) {
|
|
48
|
+
return {
|
|
49
|
+
allow: false,
|
|
50
|
+
inputType,
|
|
51
|
+
state,
|
|
52
|
+
reason,
|
|
53
|
+
command,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function isAllowedRenameCancelCallback(ctx, state) {
|
|
57
|
+
return (state.kind === "rename" &&
|
|
58
|
+
state.expectedInput === "text" &&
|
|
59
|
+
ctx.callbackQuery?.data === "rename:cancel");
|
|
60
|
+
}
|
|
61
|
+
export function resolveInteractionGuardDecision(ctx) {
|
|
62
|
+
const state = interactionManager.getSnapshot();
|
|
63
|
+
const { inputType, command } = classifyIncomingInput(ctx);
|
|
64
|
+
if (!state) {
|
|
65
|
+
return createAllowDecision(inputType, null, command);
|
|
66
|
+
}
|
|
67
|
+
if (interactionManager.isExpired()) {
|
|
68
|
+
interactionManager.clear("expired");
|
|
69
|
+
return createBlockDecision(inputType, state, "expired", command);
|
|
70
|
+
}
|
|
71
|
+
if (inputType === "command") {
|
|
72
|
+
if (command && state.allowedCommands.includes(command)) {
|
|
73
|
+
return createAllowDecision(inputType, state, command);
|
|
74
|
+
}
|
|
75
|
+
return createBlockDecision(inputType, state, "command_not_allowed", command);
|
|
76
|
+
}
|
|
77
|
+
if (state.expectedInput === "mixed") {
|
|
78
|
+
return createAllowDecision(inputType, state, command);
|
|
79
|
+
}
|
|
80
|
+
if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) {
|
|
81
|
+
return createAllowDecision(inputType, state, command);
|
|
82
|
+
}
|
|
83
|
+
if (state.expectedInput === inputType) {
|
|
84
|
+
return createAllowDecision(inputType, state, command);
|
|
85
|
+
}
|
|
86
|
+
return createBlockDecision(inputType, state, getExpectedInputBlockReason(state.expectedInput), command);
|
|
87
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
export const DEFAULT_ALLOWED_INTERACTION_COMMANDS = ["/help", "/status", "/stop"];
|
|
3
|
+
function normalizeCommand(command) {
|
|
4
|
+
const trimmed = command.trim().toLowerCase();
|
|
5
|
+
if (!trimmed) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
9
|
+
const withoutMention = withSlash.split("@")[0];
|
|
10
|
+
if (withoutMention.length <= 1) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return withoutMention;
|
|
14
|
+
}
|
|
15
|
+
function normalizeAllowedCommands(commands) {
|
|
16
|
+
if (commands === undefined) {
|
|
17
|
+
return [...DEFAULT_ALLOWED_INTERACTION_COMMANDS];
|
|
18
|
+
}
|
|
19
|
+
const normalized = new Set();
|
|
20
|
+
for (const command of commands) {
|
|
21
|
+
const value = normalizeCommand(command);
|
|
22
|
+
if (value) {
|
|
23
|
+
normalized.add(value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return Array.from(normalized);
|
|
27
|
+
}
|
|
28
|
+
function cloneState(state) {
|
|
29
|
+
return {
|
|
30
|
+
...state,
|
|
31
|
+
allowedCommands: [...state.allowedCommands],
|
|
32
|
+
metadata: { ...state.metadata },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
class InteractionManager {
|
|
36
|
+
state = null;
|
|
37
|
+
start(options) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
let expiresAt = null;
|
|
40
|
+
if (this.state) {
|
|
41
|
+
this.clear("state_replaced");
|
|
42
|
+
}
|
|
43
|
+
if (typeof options.expiresInMs === "number") {
|
|
44
|
+
expiresAt = now + options.expiresInMs;
|
|
45
|
+
}
|
|
46
|
+
const nextState = {
|
|
47
|
+
kind: options.kind,
|
|
48
|
+
expectedInput: options.expectedInput,
|
|
49
|
+
allowedCommands: normalizeAllowedCommands(options.allowedCommands),
|
|
50
|
+
metadata: options.metadata ? { ...options.metadata } : {},
|
|
51
|
+
createdAt: now,
|
|
52
|
+
expiresAt,
|
|
53
|
+
};
|
|
54
|
+
this.state = nextState;
|
|
55
|
+
logger.info(`[InteractionManager] Started interaction: kind=${nextState.kind}, expectedInput=${nextState.expectedInput}, allowedCommands=${nextState.allowedCommands.join(",") || "none"}`);
|
|
56
|
+
return cloneState(nextState);
|
|
57
|
+
}
|
|
58
|
+
get() {
|
|
59
|
+
if (!this.state) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return cloneState(this.state);
|
|
63
|
+
}
|
|
64
|
+
getSnapshot() {
|
|
65
|
+
return this.get();
|
|
66
|
+
}
|
|
67
|
+
isActive() {
|
|
68
|
+
return this.state !== null;
|
|
69
|
+
}
|
|
70
|
+
isExpired(referenceTimeMs = Date.now()) {
|
|
71
|
+
if (!this.state || this.state.expiresAt === null) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return referenceTimeMs >= this.state.expiresAt;
|
|
75
|
+
}
|
|
76
|
+
transition(options) {
|
|
77
|
+
if (!this.state) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
this.state = {
|
|
82
|
+
...this.state,
|
|
83
|
+
kind: options.kind ?? this.state.kind,
|
|
84
|
+
expectedInput: options.expectedInput ?? this.state.expectedInput,
|
|
85
|
+
allowedCommands: options.allowedCommands !== undefined
|
|
86
|
+
? normalizeAllowedCommands(options.allowedCommands)
|
|
87
|
+
: [...this.state.allowedCommands],
|
|
88
|
+
metadata: options.metadata ? { ...options.metadata } : { ...this.state.metadata },
|
|
89
|
+
expiresAt: options.expiresInMs === undefined
|
|
90
|
+
? this.state.expiresAt
|
|
91
|
+
: options.expiresInMs === null
|
|
92
|
+
? null
|
|
93
|
+
: now + options.expiresInMs,
|
|
94
|
+
};
|
|
95
|
+
logger.debug(`[InteractionManager] Transitioned interaction: kind=${this.state.kind}, expectedInput=${this.state.expectedInput}, allowedCommands=${this.state.allowedCommands.join(",") || "none"}`);
|
|
96
|
+
return cloneState(this.state);
|
|
97
|
+
}
|
|
98
|
+
clear(reason = "manual") {
|
|
99
|
+
if (!this.state) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
logger.info(`[InteractionManager] Cleared interaction: reason=${reason}, kind=${this.state.kind}, expectedInput=${this.state.expectedInput}`);
|
|
103
|
+
this.state = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export const interactionManager = new InteractionManager();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -51,6 +51,12 @@ class PermissionManager {
|
|
|
51
51
|
setMessageId(messageId) {
|
|
52
52
|
this.state.messageId = messageId;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Check if callback message ID belongs to active permission request
|
|
56
|
+
*/
|
|
57
|
+
isActiveMessage(messageId) {
|
|
58
|
+
return (this.state.isActive && this.state.messageId !== null && messageId === this.state.messageId);
|
|
59
|
+
}
|
|
54
60
|
/**
|
|
55
61
|
* Get Telegram message ID
|
|
56
62
|
*/
|
package/dist/question/manager.js
CHANGED
|
@@ -5,6 +5,8 @@ class QuestionManager {
|
|
|
5
5
|
currentIndex: 0,
|
|
6
6
|
selectedOptions: new Map(),
|
|
7
7
|
customAnswers: new Map(),
|
|
8
|
+
customInputQuestionIndex: null,
|
|
9
|
+
activeMessageId: null,
|
|
8
10
|
messageIds: [],
|
|
9
11
|
isActive: false,
|
|
10
12
|
requestID: null,
|
|
@@ -22,6 +24,8 @@ class QuestionManager {
|
|
|
22
24
|
currentIndex: 0,
|
|
23
25
|
selectedOptions: new Map(),
|
|
24
26
|
customAnswers: new Map(),
|
|
27
|
+
customInputQuestionIndex: null,
|
|
28
|
+
activeMessageId: null,
|
|
25
29
|
messageIds: [],
|
|
26
30
|
isActive: true,
|
|
27
31
|
requestID,
|
|
@@ -87,6 +91,8 @@ class QuestionManager {
|
|
|
87
91
|
}
|
|
88
92
|
nextQuestion() {
|
|
89
93
|
this.state.currentIndex++;
|
|
94
|
+
this.state.customInputQuestionIndex = null;
|
|
95
|
+
this.state.activeMessageId = null;
|
|
90
96
|
logger.debug(`[QuestionManager] Moving to next question: ${this.state.currentIndex}/${this.state.questions.length}`);
|
|
91
97
|
}
|
|
92
98
|
hasNextQuestion() {
|
|
@@ -101,6 +107,29 @@ class QuestionManager {
|
|
|
101
107
|
addMessageId(messageId) {
|
|
102
108
|
this.state.messageIds.push(messageId);
|
|
103
109
|
}
|
|
110
|
+
setActiveMessageId(messageId) {
|
|
111
|
+
this.state.activeMessageId = messageId;
|
|
112
|
+
}
|
|
113
|
+
getActiveMessageId() {
|
|
114
|
+
return this.state.activeMessageId;
|
|
115
|
+
}
|
|
116
|
+
isActiveMessage(messageId) {
|
|
117
|
+
return (this.state.isActive &&
|
|
118
|
+
this.state.activeMessageId !== null &&
|
|
119
|
+
messageId === this.state.activeMessageId);
|
|
120
|
+
}
|
|
121
|
+
startCustomInput(questionIndex) {
|
|
122
|
+
if (!this.state.isActive || !this.state.questions[questionIndex]) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.state.customInputQuestionIndex = questionIndex;
|
|
126
|
+
}
|
|
127
|
+
clearCustomInput() {
|
|
128
|
+
this.state.customInputQuestionIndex = null;
|
|
129
|
+
}
|
|
130
|
+
isWaitingForCustomInput(questionIndex) {
|
|
131
|
+
return this.state.customInputQuestionIndex === questionIndex;
|
|
132
|
+
}
|
|
104
133
|
getMessageIds() {
|
|
105
134
|
return [...this.state.messageIds];
|
|
106
135
|
}
|
|
@@ -111,6 +140,8 @@ class QuestionManager {
|
|
|
111
140
|
cancel() {
|
|
112
141
|
logger.info("[QuestionManager] Poll cancelled");
|
|
113
142
|
this.state.isActive = false;
|
|
143
|
+
this.state.customInputQuestionIndex = null;
|
|
144
|
+
this.state.activeMessageId = null;
|
|
114
145
|
}
|
|
115
146
|
clear() {
|
|
116
147
|
this.state = {
|
|
@@ -118,6 +149,8 @@ class QuestionManager {
|
|
|
118
149
|
currentIndex: 0,
|
|
119
150
|
selectedOptions: new Map(),
|
|
120
151
|
customAnswers: new Map(),
|
|
152
|
+
customInputQuestionIndex: null,
|
|
153
|
+
activeMessageId: null,
|
|
121
154
|
messageIds: [],
|
|
122
155
|
isActive: false,
|
|
123
156
|
requestID: null,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
class RenameManager {
|
|
3
|
+
state = {
|
|
4
|
+
isWaiting: false,
|
|
5
|
+
sessionId: null,
|
|
6
|
+
sessionDirectory: null,
|
|
7
|
+
currentTitle: null,
|
|
8
|
+
messageId: null,
|
|
9
|
+
};
|
|
10
|
+
startWaiting(sessionId, directory, currentTitle) {
|
|
11
|
+
logger.info(`[RenameManager] Starting rename flow for session: ${sessionId}`);
|
|
12
|
+
this.state = {
|
|
13
|
+
isWaiting: true,
|
|
14
|
+
sessionId,
|
|
15
|
+
sessionDirectory: directory,
|
|
16
|
+
currentTitle,
|
|
17
|
+
messageId: null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
setMessageId(messageId) {
|
|
21
|
+
this.state.messageId = messageId;
|
|
22
|
+
}
|
|
23
|
+
getMessageId() {
|
|
24
|
+
return this.state.messageId;
|
|
25
|
+
}
|
|
26
|
+
isActiveMessage(messageId) {
|
|
27
|
+
return (this.state.isWaiting && this.state.messageId !== null && this.state.messageId === messageId);
|
|
28
|
+
}
|
|
29
|
+
isWaitingForName() {
|
|
30
|
+
return this.state.isWaiting;
|
|
31
|
+
}
|
|
32
|
+
getSessionInfo() {
|
|
33
|
+
if (!this.state.isWaiting || !this.state.sessionId) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
sessionId: this.state.sessionId,
|
|
38
|
+
directory: this.state.sessionDirectory,
|
|
39
|
+
currentTitle: this.state.currentTitle,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
clear() {
|
|
43
|
+
logger.debug("[RenameManager] Clearing rename state");
|
|
44
|
+
this.state = {
|
|
45
|
+
isWaiting: false,
|
|
46
|
+
sessionId: null,
|
|
47
|
+
sessionDirectory: null,
|
|
48
|
+
currentTitle: null,
|
|
49
|
+
messageId: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export const renameManager = new RenameManager();
|