@grinev/opencode-telegram-bot 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/dist/agent/manager.js +92 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +26 -0
  7. package/dist/bot/commands/agent.js +16 -0
  8. package/dist/bot/commands/definitions.js +20 -0
  9. package/dist/bot/commands/help.js +7 -0
  10. package/dist/bot/commands/model.js +16 -0
  11. package/dist/bot/commands/models.js +37 -0
  12. package/dist/bot/commands/new.js +58 -0
  13. package/dist/bot/commands/opencode-start.js +87 -0
  14. package/dist/bot/commands/opencode-stop.js +46 -0
  15. package/dist/bot/commands/projects.js +104 -0
  16. package/dist/bot/commands/server-restart.js +23 -0
  17. package/dist/bot/commands/server-start.js +23 -0
  18. package/dist/bot/commands/sessions.js +240 -0
  19. package/dist/bot/commands/start.js +40 -0
  20. package/dist/bot/commands/status.js +63 -0
  21. package/dist/bot/commands/stop.js +92 -0
  22. package/dist/bot/handlers/agent.js +96 -0
  23. package/dist/bot/handlers/context.js +112 -0
  24. package/dist/bot/handlers/model.js +115 -0
  25. package/dist/bot/handlers/permission.js +158 -0
  26. package/dist/bot/handlers/question.js +294 -0
  27. package/dist/bot/handlers/variant.js +126 -0
  28. package/dist/bot/index.js +573 -0
  29. package/dist/bot/middleware/auth.js +30 -0
  30. package/dist/bot/utils/keyboard.js +66 -0
  31. package/dist/cli/args.js +97 -0
  32. package/dist/cli.js +90 -0
  33. package/dist/config.js +46 -0
  34. package/dist/index.js +26 -0
  35. package/dist/keyboard/manager.js +171 -0
  36. package/dist/keyboard/types.js +1 -0
  37. package/dist/model/manager.js +123 -0
  38. package/dist/model/types.js +26 -0
  39. package/dist/opencode/client.js +13 -0
  40. package/dist/opencode/events.js +79 -0
  41. package/dist/opencode/server.js +104 -0
  42. package/dist/permission/manager.js +78 -0
  43. package/dist/permission/types.js +1 -0
  44. package/dist/pinned/manager.js +610 -0
  45. package/dist/pinned/types.js +1 -0
  46. package/dist/pinned-message/service.js +54 -0
  47. package/dist/process/manager.js +273 -0
  48. package/dist/process/types.js +1 -0
  49. package/dist/project/manager.js +28 -0
  50. package/dist/question/manager.js +143 -0
  51. package/dist/question/types.js +1 -0
  52. package/dist/runtime/bootstrap.js +278 -0
  53. package/dist/runtime/mode.js +74 -0
  54. package/dist/runtime/paths.js +37 -0
  55. package/dist/session/manager.js +10 -0
  56. package/dist/session/state.js +24 -0
  57. package/dist/settings/manager.js +99 -0
  58. package/dist/status/formatter.js +44 -0
  59. package/dist/summary/aggregator.js +427 -0
  60. package/dist/summary/formatter.js +226 -0
  61. package/dist/utils/formatting.js +237 -0
  62. package/dist/utils/logger.js +59 -0
  63. package/dist/utils/safe-background-task.js +33 -0
  64. package/dist/variant/manager.js +103 -0
  65. package/dist/variant/types.js +1 -0
  66. package/package.json +63 -0
@@ -0,0 +1,104 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { setCurrentProject, getCurrentProject } from "../../settings/manager.js";
3
+ import { getProjects } from "../../project/manager.js";
4
+ import { clearSession } from "../../session/manager.js";
5
+ import { summaryAggregator } from "../../summary/aggregator.js";
6
+ import { pinnedMessageManager } from "../../pinned/manager.js";
7
+ import { keyboardManager } from "../../keyboard/manager.js";
8
+ import { getStoredAgent } from "../../agent/manager.js";
9
+ import { getStoredModel } from "../../model/manager.js";
10
+ import { formatVariantForButton } from "../../variant/manager.js";
11
+ import { createMainKeyboard } from "../utils/keyboard.js";
12
+ import { logger } from "../../utils/logger.js";
13
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
14
+ function formatProjectButtonLabel(label, isActive) {
15
+ const prefix = isActive ? "✅ " : "";
16
+ const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
17
+ if (label.length <= availableLength) {
18
+ return `${prefix}${label}`;
19
+ }
20
+ return `${prefix}${label.slice(0, Math.max(0, availableLength - 3))}...`;
21
+ }
22
+ export async function projectsCommand(ctx) {
23
+ try {
24
+ const projects = await getProjects();
25
+ if (projects.length === 0) {
26
+ await ctx.reply("📭 Проектов нет.");
27
+ return;
28
+ }
29
+ const keyboard = new InlineKeyboard();
30
+ const currentProject = getCurrentProject();
31
+ projects.forEach((project, index) => {
32
+ const isActive = currentProject &&
33
+ (project.id === currentProject.id || project.worktree === currentProject.worktree);
34
+ const label = project.name
35
+ ? `${index + 1}. ${project.name}`
36
+ : `${index + 1}. ${project.worktree}`;
37
+ const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
38
+ keyboard.text(labelWithCheck, `project:${project.id}`).row();
39
+ });
40
+ if (currentProject) {
41
+ const projectName = currentProject.name || currentProject.worktree;
42
+ await ctx.reply(`Выберите проект:\n\nТекущий: 🏗 ${projectName}`, { reply_markup: keyboard });
43
+ }
44
+ else {
45
+ await ctx.reply("Выберите проект:", { reply_markup: keyboard });
46
+ }
47
+ }
48
+ catch (error) {
49
+ logger.error("[Bot] Error fetching projects:", error);
50
+ await ctx.reply("🔴 OpenCode Server недоступен или произошла ошибка при получении списка проектов.");
51
+ }
52
+ }
53
+ export async function handleProjectSelect(ctx) {
54
+ const callbackQuery = ctx.callbackQuery;
55
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("project:")) {
56
+ return false;
57
+ }
58
+ const projectId = callbackQuery.data.replace("project:", "");
59
+ try {
60
+ const projects = await getProjects();
61
+ const selectedProject = projects.find((p) => p.id === projectId);
62
+ if (!selectedProject) {
63
+ throw new Error(`Project with id ${projectId} not found`);
64
+ }
65
+ logger.info(`[Bot] Project selected: ${selectedProject.name || selectedProject.worktree} (id: ${projectId})`);
66
+ setCurrentProject(selectedProject);
67
+ clearSession();
68
+ summaryAggregator.clear();
69
+ // Clear pinned message when switching projects
70
+ try {
71
+ await pinnedMessageManager.clear();
72
+ }
73
+ catch (err) {
74
+ logger.error("[Bot] Error clearing pinned message:", err);
75
+ }
76
+ // Initialize keyboard manager if not already
77
+ if (ctx.chat) {
78
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
79
+ }
80
+ // Refresh context limit for current model
81
+ await pinnedMessageManager.refreshContextLimit();
82
+ const contextLimit = pinnedMessageManager.getContextLimit();
83
+ // Reset context to 0 (no session selected) with current model's limit
84
+ keyboardManager.updateContext(0, contextLimit);
85
+ // Get current state for keyboard (with context = 0)
86
+ const currentAgent = getStoredAgent();
87
+ const currentModel = getStoredModel();
88
+ const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
89
+ const variantName = formatVariantForButton(currentModel.variant || "default");
90
+ const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
91
+ const projectName = selectedProject.name || selectedProject.worktree;
92
+ await ctx.answerCallbackQuery();
93
+ await ctx.reply(`✅ Проект выбран: ${projectName}\n\n📋 Сессия сброшена. Используйте /sessions или /new для работы с этим проектом.`, {
94
+ reply_markup: keyboard,
95
+ });
96
+ await ctx.deleteMessage();
97
+ }
98
+ catch (error) {
99
+ logger.error("[Bot] Error selecting project:", error);
100
+ await ctx.answerCallbackQuery();
101
+ await ctx.reply("🔴 Ошибка при выборе проекта.");
102
+ }
103
+ return true;
104
+ }
@@ -0,0 +1,23 @@
1
+ import { restartServer, isServerRunning } from "../../opencode/server.js";
2
+ export async function serverRestartCommand(ctx) {
3
+ try {
4
+ const wasRunning = isServerRunning();
5
+ if (wasRunning) {
6
+ await ctx.reply("🔄 Перезапускаю OpenCode сервер...");
7
+ }
8
+ else {
9
+ await ctx.reply("🚀 Запускаю OpenCode сервер...");
10
+ }
11
+ const result = await restartServer();
12
+ if (result.success) {
13
+ await ctx.reply(`✅ Сервер ${wasRunning ? "перезапущен" : "запущен"} успешно!\n\n${result.message}`);
14
+ }
15
+ else {
16
+ await ctx.reply(`🔴 Ошибка:\n\n${result.message}`);
17
+ }
18
+ }
19
+ catch (error) {
20
+ console.error("Error restarting server:", error);
21
+ await ctx.reply("🔴 Произошла ошибка при попытке перезапустить сервер.");
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import { startServer, isServerRunning } from "../../opencode/server.js";
2
+ export async function serverStartCommand(ctx) {
3
+ try {
4
+ if (isServerRunning()) {
5
+ await ctx.reply("🟢 **Сервер уже запущен**\n\nИспользуйте /server_restart для перезапуска.", {
6
+ parse_mode: "Markdown",
7
+ });
8
+ return;
9
+ }
10
+ await ctx.reply("🔄 Запускаю OpenCode сервер...");
11
+ const result = await startServer();
12
+ if (result.success) {
13
+ await ctx.reply(`✅ ${result.message}\n\nСервер готов к работе!`);
14
+ }
15
+ else {
16
+ await ctx.reply(`🔴 Ошибка запуска сервера:\n\n${result.message}`);
17
+ }
18
+ }
19
+ catch (error) {
20
+ console.error("Error starting server:", error);
21
+ await ctx.reply("🔴 Произошла ошибка при попытке запустить сервер.");
22
+ }
23
+ }
@@ -0,0 +1,240 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { opencodeClient } from "../../opencode/client.js";
3
+ import { setCurrentSession } from "../../session/manager.js";
4
+ import { getCurrentProject } from "../../settings/manager.js";
5
+ import { pinnedMessageManager } from "../../pinned/manager.js";
6
+ import { keyboardManager } from "../../keyboard/manager.js";
7
+ import { logger } from "../../utils/logger.js";
8
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
9
+ import { config } from "../../config.js";
10
+ export async function sessionsCommand(ctx) {
11
+ try {
12
+ const maxSessions = config.bot.sessionsListLimit;
13
+ const currentProject = getCurrentProject();
14
+ if (!currentProject) {
15
+ await ctx.reply("🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.");
16
+ return;
17
+ }
18
+ logger.debug(`[Sessions] Fetching sessions for directory: ${currentProject.worktree}`);
19
+ const { data: sessions, error } = await opencodeClient.session.list({
20
+ directory: currentProject.worktree,
21
+ limit: maxSessions,
22
+ });
23
+ if (error || !sessions) {
24
+ throw error || new Error("No data received from server");
25
+ }
26
+ logger.debug(`[Sessions] Found ${sessions.length} sessions`);
27
+ sessions.forEach((session) => {
28
+ logger.debug(`[Sessions] Session: ${session.title} | ${session.directory}`);
29
+ });
30
+ if (sessions.length === 0) {
31
+ await ctx.reply("📭 Сессий нет.\n\nСоздайте новую сессию командой /new.");
32
+ return;
33
+ }
34
+ const keyboard = new InlineKeyboard();
35
+ sessions.forEach((session, index) => {
36
+ const date = new Date(session.time.created).toLocaleDateString("ru-RU");
37
+ const label = `${index + 1}. ${session.title} (${date})`;
38
+ keyboard.text(label, `session:${session.id}`).row();
39
+ });
40
+ await ctx.reply("Выберите сессию:", {
41
+ reply_markup: keyboard,
42
+ });
43
+ }
44
+ catch (error) {
45
+ logger.error("[Sessions] Error fetching sessions:", error);
46
+ await ctx.reply("🔴 OpenCode Server недоступен или произошла ошибка при получении списка сессий.");
47
+ }
48
+ }
49
+ export async function handleSessionSelect(ctx) {
50
+ const callbackQuery = ctx.callbackQuery;
51
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("session:")) {
52
+ return false;
53
+ }
54
+ const sessionId = callbackQuery.data.replace("session:", "");
55
+ try {
56
+ const currentProject = getCurrentProject();
57
+ if (!currentProject) {
58
+ await ctx.answerCallbackQuery();
59
+ await ctx.reply("🔴 Проект не выбран. Используйте /projects.");
60
+ return true;
61
+ }
62
+ const { data: session, error } = await opencodeClient.session.get({
63
+ sessionID: sessionId,
64
+ directory: currentProject.worktree,
65
+ });
66
+ if (error || !session) {
67
+ throw error || new Error("Failed to get session details");
68
+ }
69
+ logger.info(`[Bot] Session selected: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
70
+ const sessionInfo = {
71
+ id: session.id,
72
+ title: session.title,
73
+ directory: currentProject.worktree,
74
+ };
75
+ setCurrentSession(sessionInfo);
76
+ await ctx.answerCallbackQuery();
77
+ let loadingMessageId = null;
78
+ if (ctx.chat) {
79
+ try {
80
+ const loadingMessage = await ctx.api.sendMessage(ctx.chat.id, `⏳ Загружаю контекст и последние сообщения...`);
81
+ loadingMessageId = loadingMessage.message_id;
82
+ }
83
+ catch (err) {
84
+ logger.error("[Sessions] Failed to send loading message:", err);
85
+ }
86
+ }
87
+ // Initialize pinned message manager if not already
88
+ if (!pinnedMessageManager.isInitialized() && ctx.chat) {
89
+ pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
90
+ }
91
+ // Initialize keyboard manager if not already
92
+ if (ctx.chat) {
93
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
94
+ }
95
+ try {
96
+ // Create new pinned message for this session
97
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
98
+ // Load context from session history (for existing sessions)
99
+ // Wait for it to complete so keyboard has correct context
100
+ await pinnedMessageManager.loadContextFromHistory(session.id, currentProject.worktree);
101
+ }
102
+ catch (err) {
103
+ logger.error("[Bot] Error initializing pinned message:", err);
104
+ }
105
+ if (ctx.chat) {
106
+ const chatId = ctx.chat.id;
107
+ // Update keyboard with loaded context (callback executes async via setImmediate, so update manually)
108
+ const contextInfo = pinnedMessageManager.getContextInfo();
109
+ if (contextInfo) {
110
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
111
+ }
112
+ // Delete loading message
113
+ if (loadingMessageId) {
114
+ try {
115
+ await ctx.api.deleteMessage(chatId, loadingMessageId);
116
+ }
117
+ catch (err) {
118
+ logger.debug("[Sessions] Failed to delete loading message:", err);
119
+ }
120
+ }
121
+ // Send session selection confirmation with updated keyboard
122
+ const keyboard = keyboardManager.getKeyboard();
123
+ try {
124
+ await ctx.api.sendMessage(chatId, `✅ Сессия выбрана: ${session.title}`, {
125
+ reply_markup: keyboard,
126
+ });
127
+ }
128
+ catch (err) {
129
+ logger.error("[Sessions] Failed to send selection message:", err);
130
+ }
131
+ // Send preview asynchronously
132
+ safeBackgroundTask({
133
+ taskName: "sessions.sendPreview",
134
+ task: () => sendSessionPreview(ctx.api, chatId, null, session.title, session.id, currentProject.worktree),
135
+ });
136
+ }
137
+ await ctx.deleteMessage();
138
+ }
139
+ catch (error) {
140
+ logger.error("[Sessions] Error selecting session:", error);
141
+ await ctx.answerCallbackQuery();
142
+ await ctx.reply("🔴 Ошибка при выборе сессии.");
143
+ }
144
+ return true;
145
+ }
146
+ const PREVIEW_MESSAGES_LIMIT = 6;
147
+ const PREVIEW_ITEM_MAX_LENGTH = 420;
148
+ const TELEGRAM_MESSAGE_LIMIT = 4096;
149
+ function extractTextParts(parts) {
150
+ const textParts = parts
151
+ .filter((part) => part.type === "text" && typeof part.text === "string")
152
+ .map((part) => part.text);
153
+ if (textParts.length === 0) {
154
+ return null;
155
+ }
156
+ const text = textParts.join("").trim();
157
+ return text.length > 0 ? text : null;
158
+ }
159
+ function truncateText(text, maxLength) {
160
+ if (text.length <= maxLength) {
161
+ return text;
162
+ }
163
+ const clipped = text.slice(0, Math.max(0, maxLength - 3)).trimEnd();
164
+ return `${clipped}...`;
165
+ }
166
+ async function loadSessionPreview(sessionId, directory) {
167
+ try {
168
+ const { data: messages, error } = await opencodeClient.session.messages({
169
+ sessionID: sessionId,
170
+ directory,
171
+ limit: PREVIEW_MESSAGES_LIMIT,
172
+ });
173
+ if (error || !messages) {
174
+ logger.warn("[Sessions] Failed to fetch session messages:", error);
175
+ return [];
176
+ }
177
+ const items = messages
178
+ .map(({ info, parts }) => {
179
+ const role = info.role;
180
+ if (role !== "user" && role !== "assistant") {
181
+ return null;
182
+ }
183
+ if (role === "assistant" && info.summary) {
184
+ return null;
185
+ }
186
+ const text = extractTextParts(parts);
187
+ if (!text) {
188
+ return null;
189
+ }
190
+ const created = info.time?.created ?? 0;
191
+ return {
192
+ role,
193
+ text: truncateText(text, PREVIEW_ITEM_MAX_LENGTH),
194
+ created,
195
+ };
196
+ })
197
+ .filter((item) => Boolean(item));
198
+ return items.sort((a, b) => a.created - b.created);
199
+ }
200
+ catch (err) {
201
+ logger.error("[Sessions] Error loading session preview:", err);
202
+ return [];
203
+ }
204
+ }
205
+ function formatSessionPreview(sessionTitle, items) {
206
+ const lines = [];
207
+ if (items.length === 0) {
208
+ lines.push("Последних сообщений нет.");
209
+ return lines.join("\n");
210
+ }
211
+ lines.push("Последние сообщения:");
212
+ items.forEach((item, index) => {
213
+ const label = item.role === "user" ? "Вы:" : "Агент:";
214
+ lines.push(`${label} ${item.text}`);
215
+ if (index < items.length - 1) {
216
+ lines.push("");
217
+ }
218
+ });
219
+ const rawMessage = lines.join("\n");
220
+ return truncateText(rawMessage, TELEGRAM_MESSAGE_LIMIT);
221
+ }
222
+ async function sendSessionPreview(api, chatId, messageId, sessionTitle, sessionId, directory) {
223
+ const previewItems = await loadSessionPreview(sessionId, directory);
224
+ const finalText = formatSessionPreview(sessionTitle, previewItems);
225
+ if (messageId) {
226
+ try {
227
+ await api.editMessageText(chatId, messageId, finalText);
228
+ return;
229
+ }
230
+ catch (err) {
231
+ logger.warn("[Sessions] Failed to edit preview message, sending new one:", err);
232
+ }
233
+ }
234
+ try {
235
+ await api.sendMessage(chatId, finalText);
236
+ }
237
+ catch (err) {
238
+ logger.error("[Sessions] Failed to send session preview message:", err);
239
+ }
240
+ }
@@ -0,0 +1,40 @@
1
+ import { createMainKeyboard } from "../utils/keyboard.js";
2
+ import { getStoredAgent } from "../../agent/manager.js";
3
+ import { getStoredModel } from "../../model/manager.js";
4
+ import { formatVariantForButton } from "../../variant/manager.js";
5
+ import { pinnedMessageManager } from "../../pinned/manager.js";
6
+ import { keyboardManager } from "../../keyboard/manager.js";
7
+ export async function startCommand(ctx) {
8
+ if (ctx.chat) {
9
+ if (!pinnedMessageManager.isInitialized()) {
10
+ pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
11
+ }
12
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
13
+ }
14
+ if (pinnedMessageManager.getContextLimit() === 0) {
15
+ await pinnedMessageManager.refreshContextLimit();
16
+ }
17
+ // Get current agent, model, and context
18
+ const currentAgent = getStoredAgent();
19
+ const currentModel = getStoredModel();
20
+ const variantName = formatVariantForButton(currentModel.variant || "default");
21
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
22
+ (pinnedMessageManager.getContextLimit() > 0
23
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
24
+ : null);
25
+ keyboardManager.updateAgent(currentAgent);
26
+ keyboardManager.updateModel(currentModel);
27
+ if (contextInfo) {
28
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
29
+ }
30
+ const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
31
+ await ctx.reply("👋 Добро пожаловать в OpenCode Telegram Bot!\n\n" +
32
+ "Используйте команды:\n" +
33
+ "/projects — выбрать проект\n" +
34
+ "/sessions — список сессий\n" +
35
+ "/new — новая сессия\n" +
36
+ "/agent — сменить режим\n" +
37
+ "/model — выбрать модель\n" +
38
+ "/status — статус\n" +
39
+ "/help — справка", { reply_markup: keyboard });
40
+ }
@@ -0,0 +1,63 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { getCurrentSession } from "../../session/manager.js";
3
+ import { getCurrentProject } from "../../settings/manager.js";
4
+ import { fetchCurrentAgent } from "../../agent/manager.js";
5
+ import { getAgentDisplayName } from "../../agent/types.js";
6
+ import { fetchCurrentModel } from "../../model/manager.js";
7
+ import { formatModelForDisplay } from "../../model/types.js";
8
+ import { processManager } from "../../process/manager.js";
9
+ import { logger } from "../../utils/logger.js";
10
+ export async function statusCommand(ctx) {
11
+ try {
12
+ const { data, error } = await opencodeClient.global.health();
13
+ if (error || !data) {
14
+ throw error || new Error("No data received from server");
15
+ }
16
+ let message = "🟢 **OpenCode Server запущен**\n\n";
17
+ message += `Статус: ${data.healthy ? "Healthy" : "Unhealthy"}\n`;
18
+ if (data.version) {
19
+ message += `Версия: ${data.version}\n`;
20
+ }
21
+ // Add process management information
22
+ if (processManager.isRunning()) {
23
+ const uptime = processManager.getUptime();
24
+ const uptimeStr = uptime ? Math.floor(uptime / 1000) : 0;
25
+ message += `Управляется ботом: Да\n`;
26
+ message += `PID: ${processManager.getPID()}\n`;
27
+ message += `Uptime: ${uptimeStr} сек\n`;
28
+ }
29
+ else {
30
+ message += `Управляется ботом: Нет\n`;
31
+ }
32
+ // Add agent mode information
33
+ const currentAgent = await fetchCurrentAgent();
34
+ const agentDisplay = currentAgent ? getAgentDisplayName(currentAgent) : "не установлен";
35
+ message += `Режим: ${agentDisplay}\n`;
36
+ // Add model information
37
+ const currentModel = fetchCurrentModel();
38
+ const modelDisplay = formatModelForDisplay(currentModel.providerID, currentModel.modelID);
39
+ message += `Модель: ${modelDisplay}\n`;
40
+ const currentProject = getCurrentProject();
41
+ if (currentProject) {
42
+ const projectName = currentProject.name || currentProject.worktree;
43
+ message += `\n🏗 Проект: ${projectName}\n`;
44
+ }
45
+ else {
46
+ message += "\n🏗 Проект: не выбран\n";
47
+ message += "Используйте /projects для выбора проекта";
48
+ }
49
+ const currentSession = getCurrentSession();
50
+ if (currentSession) {
51
+ message += `\n📋 Текущая сессия: ${currentSession.title}\n`;
52
+ }
53
+ else {
54
+ message += "\n📋 Текущая сессия: не выбрана\n";
55
+ message += "Используйте /sessions для выбора или /new для создания";
56
+ }
57
+ await ctx.reply(message, { parse_mode: "Markdown" });
58
+ }
59
+ catch (error) {
60
+ logger.error("[Bot] Error checking server status:", error);
61
+ await ctx.reply("🔴 OpenCode Server недоступен\n\n" + "Используйте /opencode_start для запуска сервера.");
62
+ }
63
+ }
@@ -0,0 +1,92 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { stopEventListening } from "../../opencode/events.js";
3
+ import { getCurrentSession } from "../../session/manager.js";
4
+ import { permissionManager } from "../../permission/manager.js";
5
+ import { questionManager } from "../../question/manager.js";
6
+ import { summaryAggregator } from "../../summary/aggregator.js";
7
+ import { logger } from "../../utils/logger.js";
8
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
+ function stopLocalStreaming() {
10
+ stopEventListening();
11
+ summaryAggregator.clear();
12
+ questionManager.clear();
13
+ permissionManager.clear();
14
+ }
15
+ async function pollSessionStatus(sessionId, directory, maxWaitMs = 5000) {
16
+ const startedAt = Date.now();
17
+ const pollIntervalMs = 500;
18
+ while (Date.now() - startedAt < maxWaitMs) {
19
+ try {
20
+ const { data, error } = await opencodeClient.session.status({ directory });
21
+ if (error || !data) {
22
+ break;
23
+ }
24
+ const sessionStatus = data[sessionId];
25
+ if (!sessionStatus) {
26
+ return "not-found";
27
+ }
28
+ if (sessionStatus.type === "idle" || sessionStatus.type === "error") {
29
+ return "idle";
30
+ }
31
+ if (sessionStatus.type !== "busy") {
32
+ return "not-found";
33
+ }
34
+ await sleep(pollIntervalMs);
35
+ }
36
+ catch (error) {
37
+ logger.warn("[Stop] Failed to poll session status:", error);
38
+ break;
39
+ }
40
+ }
41
+ return "busy";
42
+ }
43
+ export async function stopCommand(ctx) {
44
+ try {
45
+ const currentSession = getCurrentSession();
46
+ if (!currentSession) {
47
+ await ctx.reply("🛑 Агент не был запущен\n\nСначала создайте сессию командой /new или выберите существующую через /sessions.");
48
+ return;
49
+ }
50
+ stopLocalStreaming();
51
+ const waitingMessage = await ctx.reply("🛑 Отключил поток событий и отправляю сигнал прерывания...\n\nОжидание остановки агента.");
52
+ const controller = new AbortController();
53
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
54
+ try {
55
+ const { data: abortResult, error: abortError } = await opencodeClient.session.abort({
56
+ sessionID: currentSession.id,
57
+ directory: currentSession.directory,
58
+ }, { signal: controller.signal });
59
+ clearTimeout(timeoutId);
60
+ if (abortError) {
61
+ logger.warn("[Stop] Abort request failed:", abortError);
62
+ await ctx.api.editMessageText(ctx.chat.id, waitingMessage.message_id, "⚠️ Поток событий остановлен, но сервер не подтвердил прерывание.\n\nПроверьте /status и повторите /stop через пару секунд.");
63
+ return;
64
+ }
65
+ if (abortResult !== true) {
66
+ await ctx.api.editMessageText(ctx.chat.id, waitingMessage.message_id, "⚠️ Поток событий остановлен, но агент мог уже завершиться к моменту запроса.");
67
+ return;
68
+ }
69
+ const finalStatus = await pollSessionStatus(currentSession.id, currentSession.directory, 5000);
70
+ if (finalStatus === "idle" || finalStatus === "not-found") {
71
+ await ctx.api.editMessageText(ctx.chat.id, waitingMessage.message_id, "✅ Действие агента прервано. Новые сообщения от текущего запуска больше не придут.");
72
+ }
73
+ else {
74
+ await ctx.api.editMessageText(ctx.chat.id, waitingMessage.message_id, "⚠️ Сигнал отправлен, но агент еще busy.\n\nПоток событий уже отключен, поэтому бот не будет присылать промежуточные сообщения.");
75
+ }
76
+ }
77
+ catch (error) {
78
+ clearTimeout(timeoutId);
79
+ if (error instanceof Error && error.name === "AbortError") {
80
+ await ctx.api.editMessageText(ctx.chat.id, waitingMessage.message_id, "⚠️ Таймаут запроса на прерывание.\n\nПоток событий уже отключен, повторите /stop через пару секунд.");
81
+ }
82
+ else {
83
+ logger.error("[Stop] Error while aborting session:", error);
84
+ await ctx.api.editMessageText(ctx.chat.id, waitingMessage.message_id, "⚠️ Поток событий остановлен локально, но при прерывании на сервере произошла ошибка.");
85
+ }
86
+ }
87
+ }
88
+ catch (error) {
89
+ logger.error("[Stop] Unexpected error:", error);
90
+ await ctx.reply("🔴 Ошибка при прерывании действия.\n\nПоток событий остановлен, попробуйте /stop еще раз.");
91
+ }
92
+ }