opencode-telegram-group-topics-bot 0.11.2
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 +74 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/agent/manager.js +60 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +47 -0
- package/dist/bot/commands/abort.js +116 -0
- package/dist/bot/commands/commands.js +389 -0
- package/dist/bot/commands/constants.js +20 -0
- package/dist/bot/commands/definitions.js +25 -0
- package/dist/bot/commands/help.js +27 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +247 -0
- package/dist/bot/commands/opencode-start.js +85 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +304 -0
- package/dist/bot/commands/rename.js +173 -0
- package/dist/bot/commands/sessions.js +491 -0
- package/dist/bot/commands/start.js +67 -0
- package/dist/bot/commands/status.js +138 -0
- package/dist/bot/constants.js +49 -0
- package/dist/bot/handlers/agent.js +127 -0
- package/dist/bot/handlers/context.js +125 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +124 -0
- package/dist/bot/handlers/model.js +152 -0
- package/dist/bot/handlers/permission.js +281 -0
- package/dist/bot/handlers/prompt.js +263 -0
- package/dist/bot/handlers/question.js +285 -0
- package/dist/bot/handlers/variant.js +147 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +945 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/scope.js +222 -0
- package/dist/bot/telegram-constants.js +3 -0
- package/dist/bot/telegram-rate-limiter.js +263 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/keyboard.js +85 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
- package/dist/bot/utils/session-error-filter.js +34 -0
- package/dist/bot/utils/topic-link.js +29 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +103 -0
- package/dist/i18n/de.js +330 -0
- package/dist/i18n/en.js +330 -0
- package/dist/i18n/es.js +330 -0
- package/dist/i18n/index.js +102 -0
- package/dist/i18n/ru.js +330 -0
- package/dist/i18n/zh.js +330 -0
- package/dist/index.js +28 -0
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/constants.js +25 -0
- package/dist/interaction/guard.js +100 -0
- package/dist/interaction/manager.js +113 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +115 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/manager.js +257 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +159 -0
- package/dist/opencode/prompt-submit-error.js +101 -0
- package/dist/permission/manager.js +92 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/manager.js +405 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +186 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +64 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/runtime/process-error-handlers.js +24 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +87 -0
- package/dist/settings/manager.js +283 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +625 -0
- package/dist/summary/formatter.js +417 -0
- package/dist/summary/tool-message-batcher.js +277 -0
- package/dist/topic/colors.js +8 -0
- package/dist/topic/constants.js +10 -0
- package/dist/topic/manager.js +161 -0
- package/dist/topic/title-constants.js +2 -0
- package/dist/topic/title-format.js +10 -0
- package/dist/topic/title-sync.js +17 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +175 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
2
|
+
import { classifyPromptSubmitError } from "../../opencode/prompt-submit-error.js";
|
|
3
|
+
import { setCurrentSession } from "../../session/manager.js";
|
|
4
|
+
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
5
|
+
import { TOPIC_SESSION_STATUS, getCurrentProject, setCurrentAgent, setCurrentModel, setCurrentProject, } from "../../settings/manager.js";
|
|
6
|
+
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
7
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
8
|
+
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
9
|
+
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
|
+
import { getStoredAgent } from "../../agent/manager.js";
|
|
11
|
+
import { getStoredModel } from "../../model/manager.js";
|
|
12
|
+
import { formatVariantForButton } from "../../variant/manager.js";
|
|
13
|
+
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
14
|
+
import { logger } from "../../utils/logger.js";
|
|
15
|
+
import { t } from "../../i18n/index.js";
|
|
16
|
+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
17
|
+
import { GENERAL_TOPIC_THREAD_ID, GLOBAL_SCOPE_KEY, SCOPE_CONTEXT, createScopeKeyFromParams, getScopeFromContext, getThreadSendOptions, isTopicScope, } from "../scope.js";
|
|
18
|
+
import { TOPIC_COLORS } from "../../topic/colors.js";
|
|
19
|
+
import { registerTopicSessionBinding } from "../../topic/manager.js";
|
|
20
|
+
import { syncTopicTitleForSession } from "../../topic/title-sync.js";
|
|
21
|
+
import { formatTopicTitle } from "../../topic/title-format.js";
|
|
22
|
+
import { BOT_I18N_KEY, CHAT_TYPE, TELEGRAM_CHAT_FIELD, TELEGRAM_ERROR_MARKER, } from "../constants.js";
|
|
23
|
+
import { INTERACTION_CLEAR_REASON } from "../../interaction/constants.js";
|
|
24
|
+
import { buildTopicMessageLink } from "../utils/topic-link.js";
|
|
25
|
+
const NEW_COMMAND_TOPIC_SYNC = {
|
|
26
|
+
TITLE_POLL_ATTEMPTS: 8,
|
|
27
|
+
TITLE_POLL_DELAY_MS: 2000,
|
|
28
|
+
};
|
|
29
|
+
function parseNewCommandPrompt(ctx) {
|
|
30
|
+
const text = ctx.message?.text ?? "";
|
|
31
|
+
const parts = text.trim().split(/\s+/);
|
|
32
|
+
if (parts.length <= 1) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
return parts.slice(1).join(" ");
|
|
36
|
+
}
|
|
37
|
+
function wait(ms) {
|
|
38
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
}
|
|
40
|
+
async function pollSessionTitleAndSyncTopic(ctx, sessionId, directory) {
|
|
41
|
+
for (let attempt = 0; attempt < NEW_COMMAND_TOPIC_SYNC.TITLE_POLL_ATTEMPTS; attempt++) {
|
|
42
|
+
await wait(NEW_COMMAND_TOPIC_SYNC.TITLE_POLL_DELAY_MS);
|
|
43
|
+
const { data, error } = await opencodeClient.session.get({
|
|
44
|
+
sessionID: sessionId,
|
|
45
|
+
directory,
|
|
46
|
+
});
|
|
47
|
+
if (error || !data?.title) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const synced = await syncTopicTitleForSession(ctx.api, sessionId, data.title);
|
|
52
|
+
if (synced) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (renameError) {
|
|
57
|
+
logger.debug("[Bot] Failed to sync topic title during /new polling", {
|
|
58
|
+
sessionId,
|
|
59
|
+
error: renameError,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function isGeneralForumScope(ctx) {
|
|
66
|
+
const scope = getScopeFromContext(ctx);
|
|
67
|
+
const isForumEnabled = ctx.chat?.type === CHAT_TYPE.SUPERGROUP &&
|
|
68
|
+
Reflect.get(ctx.chat, TELEGRAM_CHAT_FIELD.IS_FORUM) === true;
|
|
69
|
+
return Boolean(isForumEnabled &&
|
|
70
|
+
scope?.context === SCOPE_CONTEXT.GROUP_GENERAL &&
|
|
71
|
+
(scope.threadId === null || scope.threadId === GENERAL_TOPIC_THREAD_ID));
|
|
72
|
+
}
|
|
73
|
+
function getErrorText(error) {
|
|
74
|
+
if (error instanceof Error) {
|
|
75
|
+
return error.message.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
const description = typeof error === "object" && error !== null ? Reflect.get(error, "description") : null;
|
|
78
|
+
if (typeof description === "string") {
|
|
79
|
+
return description.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
return String(error).toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
export function createNewCommand(deps) {
|
|
84
|
+
return async function newCommand(ctx) {
|
|
85
|
+
try {
|
|
86
|
+
const scope = getScopeFromContext(ctx);
|
|
87
|
+
const scopeKey = scope?.key ?? GLOBAL_SCOPE_KEY;
|
|
88
|
+
if (isTopicScope(scope)) {
|
|
89
|
+
await ctx.reply(t(BOT_I18N_KEY.NEW_TOPIC_ONLY_IN_GENERAL));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!isGeneralForumScope(ctx)) {
|
|
93
|
+
await ctx.reply(t(BOT_I18N_KEY.NEW_REQUIRES_FORUM_GENERAL));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
97
|
+
if (!currentProject) {
|
|
98
|
+
await ctx.reply(t("new.project_not_selected"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
logger.debug("[Bot] Creating new session for forum topic", {
|
|
102
|
+
scopeKey,
|
|
103
|
+
project: currentProject.worktree,
|
|
104
|
+
});
|
|
105
|
+
const { data: session, error } = await opencodeClient.session.create({
|
|
106
|
+
directory: currentProject.worktree,
|
|
107
|
+
});
|
|
108
|
+
if (error || !session) {
|
|
109
|
+
throw error || new Error("No data received from server");
|
|
110
|
+
}
|
|
111
|
+
const initialPrompt = parseNewCommandPrompt(ctx);
|
|
112
|
+
const topicTitle = formatTopicTitle(session.title, session.title);
|
|
113
|
+
const createdTopic = await ctx.api.createForumTopic(ctx.chat.id, topicTitle, {
|
|
114
|
+
icon_color: TOPIC_COLORS.BLUE,
|
|
115
|
+
});
|
|
116
|
+
const topicThreadId = createdTopic.message_thread_id;
|
|
117
|
+
const topicScopeKey = createScopeKeyFromParams({
|
|
118
|
+
chatId: ctx.chat.id,
|
|
119
|
+
threadId: topicThreadId,
|
|
120
|
+
context: SCOPE_CONTEXT.GROUP_TOPIC,
|
|
121
|
+
});
|
|
122
|
+
const sessionInfo = {
|
|
123
|
+
id: session.id,
|
|
124
|
+
title: session.title,
|
|
125
|
+
directory: currentProject.worktree,
|
|
126
|
+
};
|
|
127
|
+
setCurrentProject(currentProject, topicScopeKey);
|
|
128
|
+
setCurrentSession(sessionInfo, topicScopeKey);
|
|
129
|
+
setCurrentAgent(getStoredAgent(scopeKey), topicScopeKey);
|
|
130
|
+
setCurrentModel(getStoredModel(scopeKey), topicScopeKey);
|
|
131
|
+
registerTopicSessionBinding({
|
|
132
|
+
scopeKey: topicScopeKey,
|
|
133
|
+
chatId: ctx.chat.id,
|
|
134
|
+
threadId: topicThreadId,
|
|
135
|
+
sessionId: session.id,
|
|
136
|
+
projectId: currentProject.id,
|
|
137
|
+
projectWorktree: currentProject.worktree,
|
|
138
|
+
topicName: topicTitle,
|
|
139
|
+
status: TOPIC_SESSION_STATUS.ACTIVE,
|
|
140
|
+
});
|
|
141
|
+
await deps.ensureEventSubscription(currentProject.worktree);
|
|
142
|
+
summaryAggregator.setSession(session.id);
|
|
143
|
+
clearAllInteractionState(INTERACTION_CLEAR_REASON.SESSION_CREATED, topicScopeKey);
|
|
144
|
+
await ingestSessionInfoForCache(session);
|
|
145
|
+
if (!pinnedMessageManager.isInitialized(topicScopeKey)) {
|
|
146
|
+
pinnedMessageManager.initialize(ctx.api, ctx.chat.id, topicScopeKey, topicThreadId);
|
|
147
|
+
}
|
|
148
|
+
keyboardManager.initialize(ctx.api, ctx.chat.id, topicScopeKey);
|
|
149
|
+
try {
|
|
150
|
+
await pinnedMessageManager.onSessionChange(session.id, session.title, topicScopeKey);
|
|
151
|
+
}
|
|
152
|
+
catch (errorOnPinned) {
|
|
153
|
+
logger.error("[Bot] Error creating pinned message for new topic", errorOnPinned);
|
|
154
|
+
}
|
|
155
|
+
if (pinnedMessageManager.getContextLimit(topicScopeKey) === 0) {
|
|
156
|
+
await pinnedMessageManager.refreshContextLimit(topicScopeKey);
|
|
157
|
+
}
|
|
158
|
+
const currentAgent = getStoredAgent(topicScopeKey);
|
|
159
|
+
const currentModel = getStoredModel(topicScopeKey);
|
|
160
|
+
const contextInfo = pinnedMessageManager.getContextInfo(topicScopeKey) ??
|
|
161
|
+
keyboardManager.getContextInfo(topicScopeKey) ??
|
|
162
|
+
(pinnedMessageManager.getContextLimit(topicScopeKey) > 0
|
|
163
|
+
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit(topicScopeKey) }
|
|
164
|
+
: null);
|
|
165
|
+
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
166
|
+
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
|
|
167
|
+
const topicReadyMessage = await ctx.api.sendMessage(ctx.chat.id, t(BOT_I18N_KEY.NEW_TOPIC_CREATED, { title: session.title }), {
|
|
168
|
+
...getThreadSendOptions(topicThreadId),
|
|
169
|
+
reply_markup: keyboard,
|
|
170
|
+
});
|
|
171
|
+
const topicMessageLink = buildTopicMessageLink(ctx.chat, topicReadyMessage.message_id);
|
|
172
|
+
const generalReplyText = topicMessageLink
|
|
173
|
+
? `${t(BOT_I18N_KEY.NEW_GENERAL_CREATED)}\n${t(BOT_I18N_KEY.NEW_GENERAL_OPEN_LINK, { url: topicMessageLink })}`
|
|
174
|
+
: t(BOT_I18N_KEY.NEW_TOPIC_CREATE_ERROR);
|
|
175
|
+
await ctx.reply(generalReplyText, getThreadSendOptions(scope?.threadId ?? null));
|
|
176
|
+
if (initialPrompt.length > 0) {
|
|
177
|
+
const promptModel = getStoredModel(topicScopeKey);
|
|
178
|
+
const promptAgent = getStoredAgent(topicScopeKey);
|
|
179
|
+
const promptOptions = {
|
|
180
|
+
sessionID: session.id,
|
|
181
|
+
directory: currentProject.worktree,
|
|
182
|
+
parts: [{ type: "text", text: initialPrompt }],
|
|
183
|
+
agent: promptAgent,
|
|
184
|
+
};
|
|
185
|
+
if (promptModel.providerID && promptModel.modelID) {
|
|
186
|
+
promptOptions.model = {
|
|
187
|
+
providerID: promptModel.providerID,
|
|
188
|
+
modelID: promptModel.modelID,
|
|
189
|
+
};
|
|
190
|
+
if (promptModel.variant) {
|
|
191
|
+
promptOptions.variant = promptModel.variant;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
safeBackgroundTask({
|
|
195
|
+
taskName: "new.session.promptAsync",
|
|
196
|
+
task: () => opencodeClient.session.promptAsync(promptOptions),
|
|
197
|
+
onSuccess: async ({ error: promptError }) => {
|
|
198
|
+
if (!promptError) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const errorType = classifyPromptSubmitError(promptError);
|
|
202
|
+
const errorMessageKey = errorType === "busy"
|
|
203
|
+
? "bot.session_busy"
|
|
204
|
+
: errorType === "session_not_found"
|
|
205
|
+
? "bot.prompt_send_error_session_not_found"
|
|
206
|
+
: "bot.prompt_send_error";
|
|
207
|
+
logger.error("[Bot] OpenCode API returned an error for /new promptAsync", {
|
|
208
|
+
sessionId: session.id,
|
|
209
|
+
promptError,
|
|
210
|
+
});
|
|
211
|
+
await ctx.api.sendMessage(ctx.chat.id, t(errorMessageKey), {
|
|
212
|
+
...getThreadSendOptions(topicThreadId),
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
onError: async (promptError) => {
|
|
216
|
+
const errorType = classifyPromptSubmitError(promptError);
|
|
217
|
+
const errorMessageKey = errorType === "busy"
|
|
218
|
+
? "bot.session_busy"
|
|
219
|
+
: errorType === "session_not_found"
|
|
220
|
+
? "bot.prompt_send_error_session_not_found"
|
|
221
|
+
: "bot.prompt_send_error";
|
|
222
|
+
logger.error("[Bot] Failed to send promptAsync from /new", {
|
|
223
|
+
sessionId: session.id,
|
|
224
|
+
promptError,
|
|
225
|
+
});
|
|
226
|
+
await ctx.api.sendMessage(ctx.chat.id, t(errorMessageKey), {
|
|
227
|
+
...getThreadSendOptions(topicThreadId),
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
safeBackgroundTask({
|
|
232
|
+
taskName: "new.session.topic_title_sync",
|
|
233
|
+
task: () => pollSessionTitleAndSyncTopic(ctx, session.id, currentProject.worktree),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
logger.error("[Bot] Error creating session/topic", error);
|
|
239
|
+
const errorText = getErrorText(error);
|
|
240
|
+
if (errorText.includes(TELEGRAM_ERROR_MARKER.NOT_ENOUGH_RIGHTS_CREATE_TOPIC)) {
|
|
241
|
+
await ctx.reply(t(BOT_I18N_KEY.NEW_TOPIC_CREATE_NO_RIGHTS));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await ctx.reply(t(BOT_I18N_KEY.NEW_TOPIC_CREATE_ERROR));
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
2
|
+
import { processManager } from "../../process/manager.js";
|
|
3
|
+
import { logger } from "../../utils/logger.js";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Wait for OpenCode server to become ready by polling health endpoint
|
|
7
|
+
* @param maxWaitMs Maximum time to wait in milliseconds
|
|
8
|
+
* @returns true if server became ready, false if timeout
|
|
9
|
+
*/
|
|
10
|
+
async function waitForServerReady(maxWaitMs = 10000) {
|
|
11
|
+
const startTime = Date.now();
|
|
12
|
+
const pollInterval = 500;
|
|
13
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
14
|
+
try {
|
|
15
|
+
const { data, error } = await opencodeClient.global.health();
|
|
16
|
+
if (!error && data?.healthy) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Server not ready yet
|
|
22
|
+
}
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Command handler for /opencode-start
|
|
29
|
+
* Starts the OpenCode server process
|
|
30
|
+
*/
|
|
31
|
+
export async function opencodeStartCommand(ctx) {
|
|
32
|
+
try {
|
|
33
|
+
// 1. Check if process is already running under our management
|
|
34
|
+
if (processManager.isRunning()) {
|
|
35
|
+
const uptime = processManager.getUptime();
|
|
36
|
+
const uptimeStr = uptime ? Math.floor(uptime / 1000) : 0;
|
|
37
|
+
await ctx.reply(t("opencode_start.already_running_managed", {
|
|
38
|
+
pid: processManager.getPID() ?? "-",
|
|
39
|
+
seconds: uptimeStr,
|
|
40
|
+
}));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// 2. Check if server is accessible (external process)
|
|
44
|
+
try {
|
|
45
|
+
const { data, error } = await opencodeClient.global.health();
|
|
46
|
+
if (!error && data?.healthy) {
|
|
47
|
+
await ctx.reply(t("opencode_start.already_running_external", {
|
|
48
|
+
version: data.version || t("common.unknown"),
|
|
49
|
+
}));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Server not accessible, continue with start
|
|
55
|
+
}
|
|
56
|
+
// 3. Notify user that we're starting the server
|
|
57
|
+
const statusMessage = await ctx.reply(t("opencode_start.starting"));
|
|
58
|
+
// 4. Start the process
|
|
59
|
+
const { success, error } = await processManager.start();
|
|
60
|
+
if (!success) {
|
|
61
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_start.start_error", { error: error || t("common.unknown_error") }), { parse_mode: "Markdown" });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// 5. Wait for server to become ready
|
|
65
|
+
logger.info("[Bot] Waiting for OpenCode server to become ready...");
|
|
66
|
+
const ready = await waitForServerReady(10000);
|
|
67
|
+
if (!ready) {
|
|
68
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_start.started_not_ready", {
|
|
69
|
+
pid: processManager.getPID() ?? "-",
|
|
70
|
+
}));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// 6. Get server version and send success message
|
|
74
|
+
const { data: health } = await opencodeClient.global.health();
|
|
75
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_start.success", {
|
|
76
|
+
pid: processManager.getPID() ?? "-",
|
|
77
|
+
version: health?.version || t("common.unknown"),
|
|
78
|
+
}));
|
|
79
|
+
logger.info(`[Bot] OpenCode server started successfully, PID=${processManager.getPID()}`);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
logger.error("[Bot] Error in /opencode-start command:", err);
|
|
83
|
+
await ctx.reply(t("opencode_start.error"));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
2
|
+
import { processManager } from "../../process/manager.js";
|
|
3
|
+
import { logger } from "../../utils/logger.js";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Command handler for /opencode-stop
|
|
7
|
+
* Stops the OpenCode server process
|
|
8
|
+
*/
|
|
9
|
+
export async function opencodeStopCommand(ctx) {
|
|
10
|
+
try {
|
|
11
|
+
// 1. Check if process is running under our management
|
|
12
|
+
if (!processManager.isRunning()) {
|
|
13
|
+
// Check if there's an external server running
|
|
14
|
+
try {
|
|
15
|
+
const { data, error } = await opencodeClient.global.health();
|
|
16
|
+
if (!error && data?.healthy) {
|
|
17
|
+
await ctx.reply(t("opencode_stop.external_running"));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Server not accessible
|
|
23
|
+
}
|
|
24
|
+
await ctx.reply(t("opencode_stop.not_running"));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// 2. Notify user that we're stopping the server
|
|
28
|
+
const pid = processManager.getPID();
|
|
29
|
+
const statusMessage = await ctx.reply(t("opencode_stop.stopping", { pid: pid ?? "-" }));
|
|
30
|
+
// 3. Stop the process
|
|
31
|
+
const { success, error } = await processManager.stop(5000);
|
|
32
|
+
if (!success) {
|
|
33
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_stop.stop_error", { error: error || t("common.unknown_error") }));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// 4. Success - process has been stopped
|
|
37
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_stop.success"));
|
|
38
|
+
logger.info("[Bot] OpenCode server stopped successfully");
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
logger.error("[Bot] Error in /opencode-stop command:", err);
|
|
42
|
+
await ctx.reply(t("opencode_stop.error"));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { setCurrentProject, getCurrentProject } from "../../settings/manager.js";
|
|
3
|
+
import { getProjects } from "../../project/manager.js";
|
|
4
|
+
import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
|
|
5
|
+
import { clearSession } from "../../session/manager.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 { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
12
|
+
import { INTERACTION_CLEAR_REASON } from "../../interaction/constants.js";
|
|
13
|
+
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
14
|
+
import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
|
|
15
|
+
import { logger } from "../../utils/logger.js";
|
|
16
|
+
import { t } from "../../i18n/index.js";
|
|
17
|
+
import { config } from "../../config.js";
|
|
18
|
+
import { GLOBAL_SCOPE_KEY, SCOPE_CONTEXT, getScopeFromContext, getScopeFromKey, getScopeKeyFromContext, getThreadSendOptions, } from "../scope.js";
|
|
19
|
+
import { getTopicBindingsByChat } from "../../topic/manager.js";
|
|
20
|
+
import { BOT_I18N_KEY, TELEGRAM_CHAT_FIELD } from "../constants.js";
|
|
21
|
+
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
22
|
+
const PROJECT_PAGE_CALLBACK_PREFIX = "projects:page:";
|
|
23
|
+
const PROJECT_SELECT_CALLBACK_PREFIX = "project:";
|
|
24
|
+
function formatProjectButtonLabel(label, isActive) {
|
|
25
|
+
const prefix = isActive ? "✅ " : "";
|
|
26
|
+
const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
|
|
27
|
+
if (label.length <= availableLength) {
|
|
28
|
+
return `${prefix}${label}`;
|
|
29
|
+
}
|
|
30
|
+
return `${prefix}${label.slice(0, Math.max(0, availableLength - 3))}...`;
|
|
31
|
+
}
|
|
32
|
+
export function getProjectFolderName(worktree) {
|
|
33
|
+
const normalized = worktree.replace(/[\\/]+$/g, "");
|
|
34
|
+
if (!normalized) {
|
|
35
|
+
return worktree;
|
|
36
|
+
}
|
|
37
|
+
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
|
38
|
+
return segments.at(-1) ?? normalized;
|
|
39
|
+
}
|
|
40
|
+
export function buildProjectButtonLabel(index, worktree) {
|
|
41
|
+
const folderName = getProjectFolderName(worktree);
|
|
42
|
+
return `${index + 1}. ${folderName} [${worktree}]`;
|
|
43
|
+
}
|
|
44
|
+
export function parseProjectPageCallback(data) {
|
|
45
|
+
if (!data.startsWith(PROJECT_PAGE_CALLBACK_PREFIX)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const rawPage = data.slice(PROJECT_PAGE_CALLBACK_PREFIX.length);
|
|
49
|
+
if (!/^\d+$/.test(rawPage)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return Number.parseInt(rawPage, 10);
|
|
53
|
+
}
|
|
54
|
+
export function calculateProjectsPaginationRange(totalProjects, page, pageSize) {
|
|
55
|
+
const safePageSize = Math.max(1, pageSize);
|
|
56
|
+
const totalPages = Math.max(1, Math.ceil(totalProjects / safePageSize));
|
|
57
|
+
const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
|
|
58
|
+
const startIndex = normalizedPage * safePageSize;
|
|
59
|
+
const endIndex = Math.min(startIndex + safePageSize, totalProjects);
|
|
60
|
+
return {
|
|
61
|
+
page: normalizedPage,
|
|
62
|
+
totalPages,
|
|
63
|
+
startIndex,
|
|
64
|
+
endIndex,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function buildProjectsMenuText(currentProjectName, page, totalPages) {
|
|
68
|
+
const baseText = currentProjectName
|
|
69
|
+
? t("projects.select_with_current", {
|
|
70
|
+
project: currentProjectName,
|
|
71
|
+
})
|
|
72
|
+
: t("projects.select");
|
|
73
|
+
if (totalPages <= 1) {
|
|
74
|
+
return baseText;
|
|
75
|
+
}
|
|
76
|
+
return `${baseText}\n\n${t("projects.page_indicator", {
|
|
77
|
+
current: String(page + 1),
|
|
78
|
+
total: String(totalPages),
|
|
79
|
+
})}`;
|
|
80
|
+
}
|
|
81
|
+
function buildProjectsKeyboard(projects, page, scopeKey) {
|
|
82
|
+
const keyboard = new InlineKeyboard();
|
|
83
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
84
|
+
const pageSize = config.bot.projectsListLimit;
|
|
85
|
+
const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(projects.length, page, pageSize);
|
|
86
|
+
projects.slice(startIndex, endIndex).forEach((project, index) => {
|
|
87
|
+
const isActive = currentProject &&
|
|
88
|
+
(project.id === currentProject.id || project.worktree === currentProject.worktree);
|
|
89
|
+
const label = buildProjectButtonLabel(startIndex + index, project.worktree);
|
|
90
|
+
const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
|
|
91
|
+
keyboard.text(labelWithCheck, `project:${project.id}`).row();
|
|
92
|
+
});
|
|
93
|
+
if (totalPages > 1) {
|
|
94
|
+
if (normalizedPage > 0) {
|
|
95
|
+
keyboard.text(t("projects.prev_page"), `${PROJECT_PAGE_CALLBACK_PREFIX}${normalizedPage - 1}`);
|
|
96
|
+
}
|
|
97
|
+
if (normalizedPage < totalPages - 1) {
|
|
98
|
+
keyboard.text(t("projects.next_page"), `${PROJECT_PAGE_CALLBACK_PREFIX}${normalizedPage + 1}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return keyboard;
|
|
102
|
+
}
|
|
103
|
+
function buildProjectsMenuView(projects, page, scopeKey) {
|
|
104
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
105
|
+
const pageSize = config.bot.projectsListLimit;
|
|
106
|
+
const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(projects.length, page, pageSize);
|
|
107
|
+
const currentProjectName = currentProject?.name || currentProject?.worktree || null;
|
|
108
|
+
return {
|
|
109
|
+
text: buildProjectsMenuText(currentProjectName, normalizedPage, totalPages),
|
|
110
|
+
keyboard: buildProjectsKeyboard(projects, normalizedPage, scopeKey),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function clearInteractionWithScope(reason, scopeKey) {
|
|
114
|
+
if (scopeKey === GLOBAL_SCOPE_KEY) {
|
|
115
|
+
clearAllInteractionState(reason);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
clearAllInteractionState(reason, scopeKey);
|
|
119
|
+
}
|
|
120
|
+
function getConfiguredProjectName(scopeKey, chatId) {
|
|
121
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
122
|
+
if (currentProject?.name) {
|
|
123
|
+
return currentProject.name;
|
|
124
|
+
}
|
|
125
|
+
if (currentProject?.worktree) {
|
|
126
|
+
return currentProject.worktree;
|
|
127
|
+
}
|
|
128
|
+
const topicBinding = getTopicBindingsByChat(chatId).find((binding) => Boolean(binding.projectWorktree));
|
|
129
|
+
return topicBinding?.projectWorktree ?? t("pinned.unknown");
|
|
130
|
+
}
|
|
131
|
+
function getProjectLockState(ctx, scopeKey) {
|
|
132
|
+
if (!ctx.chat) {
|
|
133
|
+
return { locked: false };
|
|
134
|
+
}
|
|
135
|
+
const scope = getScopeFromContext(ctx);
|
|
136
|
+
if (scope?.context === SCOPE_CONTEXT.GROUP_TOPIC) {
|
|
137
|
+
return {
|
|
138
|
+
locked: true,
|
|
139
|
+
messageKey: BOT_I18N_KEY.PROJECTS_LOCKED_TOPIC_SCOPE,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const isForumEnabled = Reflect.get(ctx.chat, TELEGRAM_CHAT_FIELD.IS_FORUM) === true;
|
|
143
|
+
if (!isForumEnabled) {
|
|
144
|
+
return { locked: false };
|
|
145
|
+
}
|
|
146
|
+
const hasTopicBindings = getTopicBindingsByChat(ctx.chat.id).length > 0;
|
|
147
|
+
if (!hasTopicBindings) {
|
|
148
|
+
return { locked: false };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
locked: true,
|
|
152
|
+
messageKey: BOT_I18N_KEY.PROJECTS_LOCKED_GROUP_PROJECT,
|
|
153
|
+
projectName: getConfiguredProjectName(scopeKey, ctx.chat.id),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export async function projectsCommand(ctx) {
|
|
157
|
+
try {
|
|
158
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
159
|
+
const lockState = getProjectLockState(ctx, scopeKey);
|
|
160
|
+
if (lockState.locked) {
|
|
161
|
+
const message = lockState.messageKey === BOT_I18N_KEY.PROJECTS_LOCKED_GROUP_PROJECT
|
|
162
|
+
? t(BOT_I18N_KEY.PROJECTS_LOCKED_GROUP_PROJECT, {
|
|
163
|
+
project: lockState.projectName ?? t("pinned.unknown"),
|
|
164
|
+
})
|
|
165
|
+
: t(BOT_I18N_KEY.PROJECTS_LOCKED_TOPIC_SCOPE);
|
|
166
|
+
await ctx.reply(message, getThreadSendOptions(getScopeFromContext(ctx)?.threadId ?? null));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await syncSessionDirectoryCache();
|
|
170
|
+
const projects = await getProjects();
|
|
171
|
+
if (projects.length === 0) {
|
|
172
|
+
await ctx.reply(t("projects.empty"));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const { text, keyboard } = buildProjectsMenuView(projects, 0, scopeKey);
|
|
176
|
+
await replyWithInlineMenu(ctx, {
|
|
177
|
+
menuKind: "project",
|
|
178
|
+
text,
|
|
179
|
+
keyboard,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
logger.error("[Bot] Error fetching projects:", error);
|
|
184
|
+
await ctx.reply(t("projects.fetch_error"));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export async function handleProjectSelect(ctx) {
|
|
188
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
189
|
+
const usePinned = ctx.chat?.type !== "private";
|
|
190
|
+
const callbackQuery = ctx.callbackQuery;
|
|
191
|
+
if (!callbackQuery?.data) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const page = parseProjectPageCallback(callbackQuery.data);
|
|
195
|
+
if (page !== null) {
|
|
196
|
+
const lockState = getProjectLockState(ctx, scopeKey);
|
|
197
|
+
if (lockState.locked) {
|
|
198
|
+
await ctx.answerCallbackQuery({
|
|
199
|
+
text: t(BOT_I18N_KEY.PROJECTS_LOCKED_CALLBACK),
|
|
200
|
+
show_alert: true,
|
|
201
|
+
});
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
|
|
205
|
+
if (!isActiveMenu) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const projects = await getProjects();
|
|
210
|
+
if (projects.length === 0) {
|
|
211
|
+
await ctx.answerCallbackQuery();
|
|
212
|
+
await ctx.reply(t("projects.empty"));
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
const { text, keyboard } = buildProjectsMenuView(projects, page, scopeKey);
|
|
216
|
+
await ctx.answerCallbackQuery();
|
|
217
|
+
await ctx.editMessageText(text, {
|
|
218
|
+
reply_markup: appendInlineMenuCancelButton(keyboard, "project"),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
logger.error("[Bot] Error switching projects page:", error);
|
|
223
|
+
await ctx.answerCallbackQuery({ text: t("projects.page_load_error") });
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
if (!callbackQuery.data.startsWith(PROJECT_SELECT_CALLBACK_PREFIX)) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
const lockState = getProjectLockState(ctx, scopeKey);
|
|
231
|
+
if (lockState.locked) {
|
|
232
|
+
await ctx.answerCallbackQuery({
|
|
233
|
+
text: t(BOT_I18N_KEY.PROJECTS_LOCKED_CALLBACK),
|
|
234
|
+
show_alert: true,
|
|
235
|
+
});
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
const projectId = callbackQuery.data.replace(PROJECT_SELECT_CALLBACK_PREFIX, "");
|
|
239
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
|
|
240
|
+
if (!isActiveMenu) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const projects = await getProjects();
|
|
245
|
+
const selectedProject = projects.find((p) => p.id === projectId);
|
|
246
|
+
if (!selectedProject) {
|
|
247
|
+
throw new Error(`Project with id ${projectId} not found`);
|
|
248
|
+
}
|
|
249
|
+
logger.info(`[Bot] Project selected: ${selectedProject.name || selectedProject.worktree} (id: ${projectId})`);
|
|
250
|
+
setCurrentProject(selectedProject, scopeKey);
|
|
251
|
+
clearSession(scopeKey);
|
|
252
|
+
clearInteractionWithScope(INTERACTION_CLEAR_REASON.PROJECT_SWITCHED, scopeKey);
|
|
253
|
+
// Clear pinned message when switching projects
|
|
254
|
+
if (usePinned) {
|
|
255
|
+
try {
|
|
256
|
+
await pinnedMessageManager.clear(scopeKey);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
logger.error("[Bot] Error clearing pinned message:", err);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Initialize keyboard manager if not already
|
|
263
|
+
if (ctx.chat) {
|
|
264
|
+
keyboardManager.initialize(ctx.api, ctx.chat.id, scopeKey);
|
|
265
|
+
}
|
|
266
|
+
// Refresh context limit for current model
|
|
267
|
+
if (usePinned) {
|
|
268
|
+
await pinnedMessageManager.refreshContextLimit(scopeKey);
|
|
269
|
+
}
|
|
270
|
+
const contextLimit = usePinned ? pinnedMessageManager.getContextLimit(scopeKey) : 0;
|
|
271
|
+
// Reset context to 0 (no session selected) with current model's limit
|
|
272
|
+
if (contextLimit > 0) {
|
|
273
|
+
keyboardManager.updateContext(0, contextLimit, scopeKey);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
keyboardManager.clearContext(scopeKey);
|
|
277
|
+
}
|
|
278
|
+
// Get current state for keyboard (with context = 0)
|
|
279
|
+
const currentAgent = getStoredAgent(scopeKey);
|
|
280
|
+
const currentModel = getStoredModel(scopeKey);
|
|
281
|
+
const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
|
|
282
|
+
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
283
|
+
const scope = getScopeFromKey(scopeKey);
|
|
284
|
+
const scopedKeyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName, scope?.context === SCOPE_CONTEXT.GROUP_GENERAL
|
|
285
|
+
? {
|
|
286
|
+
contextFirst: true,
|
|
287
|
+
contextLabel: t("keyboard.general_defaults"),
|
|
288
|
+
}
|
|
289
|
+
: undefined);
|
|
290
|
+
const projectName = selectedProject.name || selectedProject.worktree;
|
|
291
|
+
await ctx.answerCallbackQuery();
|
|
292
|
+
await ctx.reply(t("projects.selected", { project: projectName }), {
|
|
293
|
+
reply_markup: scopedKeyboard,
|
|
294
|
+
});
|
|
295
|
+
await ctx.deleteMessage();
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
clearInteractionWithScope(INTERACTION_CLEAR_REASON.PROJECT_SELECT_ERROR, scopeKey);
|
|
299
|
+
logger.error("[Bot] Error selecting project:", error);
|
|
300
|
+
await ctx.answerCallbackQuery();
|
|
301
|
+
await ctx.reply(t("projects.select_error"));
|
|
302
|
+
}
|
|
303
|
+
return true;
|
|
304
|
+
}
|