@grinev/opencode-telegram-bot 0.16.1 → 0.17.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.
@@ -0,0 +1,376 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { opencodeClient } from "../../opencode/client.js";
3
+ import { getCurrentProject } from "../../settings/manager.js";
4
+ import { interactionManager } from "../../interaction/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { t } from "../../i18n/index.js";
7
+ import { config } from "../../config.js";
8
+ import { processUserPrompt } from "../handlers/prompt.js";
9
+ const SKILLS_CALLBACK_PREFIX = "skills:";
10
+ const SKILLS_CALLBACK_SELECT_PREFIX = `${SKILLS_CALLBACK_PREFIX}select:`;
11
+ const SKILLS_CALLBACK_PAGE_PREFIX = `${SKILLS_CALLBACK_PREFIX}page:`;
12
+ const SKILLS_CALLBACK_CANCEL = `${SKILLS_CALLBACK_PREFIX}cancel`;
13
+ const SKILLS_CALLBACK_EXECUTE = `${SKILLS_CALLBACK_PREFIX}execute`;
14
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
15
+ function formatExecutingSkillMessage(skillName, args) {
16
+ const prefix = t("skills.executing_prefix");
17
+ const skillText = `/${skillName}`;
18
+ const argsSuffix = args ? ` ${args}` : "";
19
+ return {
20
+ text: `${prefix}\n${skillText}${argsSuffix}`,
21
+ entities: [
22
+ {
23
+ type: "code",
24
+ offset: prefix.length + 1,
25
+ length: skillText.length,
26
+ },
27
+ ],
28
+ };
29
+ }
30
+ export function buildSkillPageCallback(page) {
31
+ return `${SKILLS_CALLBACK_PAGE_PREFIX}${page}`;
32
+ }
33
+ export function parseSkillPageCallback(data) {
34
+ if (!data.startsWith(SKILLS_CALLBACK_PAGE_PREFIX)) {
35
+ return null;
36
+ }
37
+ const rawPage = data.slice(SKILLS_CALLBACK_PAGE_PREFIX.length);
38
+ const page = Number(rawPage);
39
+ if (!Number.isInteger(page) || page < 0) {
40
+ return null;
41
+ }
42
+ return page;
43
+ }
44
+ export function formatSkillsSelectText(page) {
45
+ if (page === 0) {
46
+ return t("skills.select");
47
+ }
48
+ return t("skills.select_page", { page: page + 1 });
49
+ }
50
+ function normalizeDirectoryForCommandApi(directory) {
51
+ return directory.replace(/\\/g, "/");
52
+ }
53
+ function getCallbackMessageId(ctx) {
54
+ const message = ctx.callbackQuery?.message;
55
+ if (!message || !("message_id" in message)) {
56
+ return null;
57
+ }
58
+ const messageId = message.message_id;
59
+ return typeof messageId === "number" ? messageId : null;
60
+ }
61
+ function formatSkillButtonLabel(skill) {
62
+ const description = skill.description?.trim() || t("skills.no_description");
63
+ const rawLabel = `/${skill.name} - ${description}`;
64
+ if (rawLabel.length <= MAX_INLINE_BUTTON_LABEL_LENGTH) {
65
+ return rawLabel;
66
+ }
67
+ return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
68
+ }
69
+ export function calculateSkillsPaginationRange(totalSkills, page, pageSize) {
70
+ const safePageSize = Math.max(1, pageSize);
71
+ const totalPages = Math.max(1, Math.ceil(totalSkills / safePageSize));
72
+ const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
73
+ const startIndex = normalizedPage * safePageSize;
74
+ const endIndex = Math.min(startIndex + safePageSize, totalSkills);
75
+ return {
76
+ page: normalizedPage,
77
+ totalPages,
78
+ startIndex,
79
+ endIndex,
80
+ };
81
+ }
82
+ function buildSkillsListKeyboard(skills, page, pageSize) {
83
+ const keyboard = new InlineKeyboard();
84
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateSkillsPaginationRange(skills.length, page, pageSize);
85
+ skills.slice(startIndex, endIndex).forEach((skill, index) => {
86
+ const globalIndex = startIndex + index;
87
+ keyboard
88
+ .text(formatSkillButtonLabel(skill), `${SKILLS_CALLBACK_SELECT_PREFIX}${globalIndex}`)
89
+ .row();
90
+ });
91
+ if (totalPages > 1) {
92
+ if (normalizedPage > 0) {
93
+ keyboard.text(t("skills.button.prev_page"), buildSkillPageCallback(normalizedPage - 1));
94
+ }
95
+ if (normalizedPage < totalPages - 1) {
96
+ keyboard.text(t("skills.button.next_page"), buildSkillPageCallback(normalizedPage + 1));
97
+ }
98
+ keyboard.row();
99
+ }
100
+ keyboard.text(t("skills.button.cancel"), SKILLS_CALLBACK_CANCEL);
101
+ return keyboard;
102
+ }
103
+ function buildSkillsConfirmKeyboard() {
104
+ return new InlineKeyboard()
105
+ .text(t("skills.button.execute"), SKILLS_CALLBACK_EXECUTE)
106
+ .text(t("skills.button.cancel"), SKILLS_CALLBACK_CANCEL);
107
+ }
108
+ function parseSkillItems(value) {
109
+ if (!Array.isArray(value)) {
110
+ return null;
111
+ }
112
+ const skills = [];
113
+ for (const item of value) {
114
+ if (!item || typeof item !== "object") {
115
+ return null;
116
+ }
117
+ const skillName = item.name;
118
+ if (typeof skillName !== "string" || !skillName.trim()) {
119
+ return null;
120
+ }
121
+ const description = item.description;
122
+ skills.push({
123
+ name: skillName,
124
+ description: typeof description === "string" ? description : undefined,
125
+ });
126
+ }
127
+ return skills;
128
+ }
129
+ function parseSkillsMetadata(state) {
130
+ if (!state || state.kind !== "custom") {
131
+ return null;
132
+ }
133
+ const flow = state.metadata.flow;
134
+ const stage = state.metadata.stage;
135
+ const messageId = state.metadata.messageId;
136
+ const projectDirectory = state.metadata.projectDirectory;
137
+ if (flow !== "skills" || typeof messageId !== "number" || typeof projectDirectory !== "string") {
138
+ return null;
139
+ }
140
+ if (stage === "list") {
141
+ const skills = parseSkillItems(state.metadata.skills);
142
+ if (!skills) {
143
+ return null;
144
+ }
145
+ const page = typeof state.metadata.page === "number" && Number.isInteger(state.metadata.page)
146
+ ? Math.max(0, state.metadata.page)
147
+ : 0;
148
+ return {
149
+ flow,
150
+ stage,
151
+ messageId,
152
+ projectDirectory,
153
+ skills,
154
+ page,
155
+ };
156
+ }
157
+ if (stage === "confirm") {
158
+ const skillName = state.metadata.skillName;
159
+ if (typeof skillName !== "string" || !skillName.trim()) {
160
+ return null;
161
+ }
162
+ return {
163
+ flow,
164
+ stage,
165
+ messageId,
166
+ projectDirectory,
167
+ skillName,
168
+ };
169
+ }
170
+ return null;
171
+ }
172
+ function clearSkillsInteraction(reason) {
173
+ const metadata = parseSkillsMetadata(interactionManager.getSnapshot());
174
+ if (metadata) {
175
+ interactionManager.clear(reason);
176
+ }
177
+ }
178
+ async function getSkillList(projectDirectory) {
179
+ const { data, error } = await opencodeClient.command.list({
180
+ directory: normalizeDirectoryForCommandApi(projectDirectory),
181
+ });
182
+ if (error || !data) {
183
+ throw error || new Error("No skill data received");
184
+ }
185
+ return data
186
+ .filter((skill) => {
187
+ const source = skill.source;
188
+ return typeof skill.name === "string" && skill.name.trim().length > 0 && source === "skill";
189
+ })
190
+ .map((skill) => ({
191
+ name: skill.name,
192
+ description: skill.description,
193
+ }));
194
+ }
195
+ function parseSelectIndex(data) {
196
+ if (!data.startsWith(SKILLS_CALLBACK_SELECT_PREFIX)) {
197
+ return null;
198
+ }
199
+ const rawIndex = data.slice(SKILLS_CALLBACK_SELECT_PREFIX.length);
200
+ const index = Number(rawIndex);
201
+ if (!Number.isInteger(index) || index < 0) {
202
+ return null;
203
+ }
204
+ return index;
205
+ }
206
+ async function executeSkill(ctx, deps, params) {
207
+ const currentProject = getCurrentProject();
208
+ if (!currentProject) {
209
+ await ctx.reply(t("bot.project_not_selected"));
210
+ return;
211
+ }
212
+ if (currentProject.worktree !== params.projectDirectory) {
213
+ logger.warn(`[Skills] Project changed between selection and execution. listedProject=${params.projectDirectory}, currentProject=${currentProject.worktree}. Using current project.`);
214
+ }
215
+ const args = params.argumentsText.trim();
216
+ const executingMessage = formatExecutingSkillMessage(params.skillName, args);
217
+ await ctx.reply(executingMessage.text, { entities: executingMessage.entities });
218
+ const promptText = args ? `/${params.skillName} ${args}` : `/${params.skillName}`;
219
+ await processUserPrompt(ctx, promptText, deps);
220
+ }
221
+ export async function skillsCommand(ctx) {
222
+ try {
223
+ const currentProject = getCurrentProject();
224
+ if (!currentProject) {
225
+ await ctx.reply(t("bot.project_not_selected"));
226
+ return;
227
+ }
228
+ const skills = await getSkillList(currentProject.worktree);
229
+ if (skills.length === 0) {
230
+ await ctx.reply(t("skills.empty"));
231
+ return;
232
+ }
233
+ const pageSize = config.bot.commandsListLimit;
234
+ const keyboard = buildSkillsListKeyboard(skills, 0, pageSize);
235
+ const message = await ctx.reply(formatSkillsSelectText(0), {
236
+ reply_markup: keyboard,
237
+ });
238
+ interactionManager.start({
239
+ kind: "custom",
240
+ expectedInput: "callback",
241
+ metadata: {
242
+ flow: "skills",
243
+ stage: "list",
244
+ messageId: message.message_id,
245
+ projectDirectory: currentProject.worktree,
246
+ skills,
247
+ page: 0,
248
+ },
249
+ });
250
+ }
251
+ catch (error) {
252
+ logger.error("[Skills] Error fetching skills list:", error);
253
+ await ctx.reply(t("skills.fetch_error"));
254
+ }
255
+ }
256
+ export async function handleSkillsCallback(ctx, deps) {
257
+ const data = ctx.callbackQuery?.data;
258
+ if (!data || !data.startsWith(SKILLS_CALLBACK_PREFIX)) {
259
+ return false;
260
+ }
261
+ const metadata = parseSkillsMetadata(interactionManager.getSnapshot());
262
+ const callbackMessageId = getCallbackMessageId(ctx);
263
+ if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
264
+ await ctx.answerCallbackQuery({ text: t("skills.inactive_callback"), show_alert: true });
265
+ return true;
266
+ }
267
+ try {
268
+ if (data === SKILLS_CALLBACK_CANCEL) {
269
+ clearSkillsInteraction("skills_cancelled");
270
+ await ctx.answerCallbackQuery({ text: t("skills.cancelled_callback") });
271
+ await ctx.deleteMessage().catch(() => { });
272
+ return true;
273
+ }
274
+ if (data === SKILLS_CALLBACK_EXECUTE) {
275
+ if (metadata.stage !== "confirm") {
276
+ await ctx.answerCallbackQuery({ text: t("skills.inactive_callback"), show_alert: true });
277
+ return true;
278
+ }
279
+ clearSkillsInteraction("skills_execute_clicked");
280
+ await ctx.answerCallbackQuery({ text: t("skills.execute_callback") });
281
+ await ctx.deleteMessage().catch(() => { });
282
+ await executeSkill(ctx, deps, {
283
+ projectDirectory: metadata.projectDirectory,
284
+ skillName: metadata.skillName,
285
+ argumentsText: "",
286
+ });
287
+ return true;
288
+ }
289
+ const page = parseSkillPageCallback(data);
290
+ if (page !== null) {
291
+ if (metadata.stage !== "list") {
292
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
293
+ return true;
294
+ }
295
+ const pageSize = config.bot.commandsListLimit;
296
+ const { page: normalizedPage, totalPages } = calculateSkillsPaginationRange(metadata.skills.length, page, pageSize);
297
+ if (page >= totalPages || page < 0) {
298
+ await ctx.answerCallbackQuery({ text: t("skills.page_empty_callback") });
299
+ return true;
300
+ }
301
+ const keyboard = buildSkillsListKeyboard(metadata.skills, normalizedPage, pageSize);
302
+ await ctx.editMessageText(formatSkillsSelectText(normalizedPage), {
303
+ reply_markup: keyboard,
304
+ });
305
+ await ctx.answerCallbackQuery();
306
+ interactionManager.transition({
307
+ expectedInput: "callback",
308
+ metadata: {
309
+ flow: "skills",
310
+ stage: "list",
311
+ messageId: metadata.messageId,
312
+ projectDirectory: metadata.projectDirectory,
313
+ skills: metadata.skills,
314
+ page: normalizedPage,
315
+ },
316
+ });
317
+ return true;
318
+ }
319
+ const skillIndex = parseSelectIndex(data);
320
+ if (skillIndex === null || metadata.stage !== "list") {
321
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
322
+ return true;
323
+ }
324
+ const selectedSkill = metadata.skills[skillIndex];
325
+ if (!selectedSkill) {
326
+ await ctx.answerCallbackQuery({ text: t("skills.inactive_callback"), show_alert: true });
327
+ return true;
328
+ }
329
+ await ctx.answerCallbackQuery();
330
+ await ctx.editMessageText(t("skills.confirm", { skill: `/${selectedSkill.name}` }), {
331
+ reply_markup: buildSkillsConfirmKeyboard(),
332
+ });
333
+ interactionManager.transition({
334
+ expectedInput: "mixed",
335
+ metadata: {
336
+ flow: "skills",
337
+ stage: "confirm",
338
+ messageId: metadata.messageId,
339
+ projectDirectory: metadata.projectDirectory,
340
+ skillName: selectedSkill.name,
341
+ },
342
+ });
343
+ return true;
344
+ }
345
+ catch (error) {
346
+ logger.error("[Skills] Error handling skill callback:", error);
347
+ clearSkillsInteraction("skills_callback_error");
348
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
349
+ return true;
350
+ }
351
+ }
352
+ export async function handleSkillTextArguments(ctx, deps) {
353
+ const text = ctx.message?.text;
354
+ if (!text || text.startsWith("/")) {
355
+ return false;
356
+ }
357
+ const metadata = parseSkillsMetadata(interactionManager.getSnapshot());
358
+ if (!metadata || metadata.stage !== "confirm") {
359
+ return false;
360
+ }
361
+ const argumentsText = text.trim();
362
+ if (!argumentsText) {
363
+ await ctx.reply(t("skills.arguments_empty"));
364
+ return true;
365
+ }
366
+ clearSkillsInteraction("skills_arguments_submitted");
367
+ if (ctx.chat) {
368
+ await ctx.api.deleteMessage(ctx.chat.id, metadata.messageId).catch(() => { });
369
+ }
370
+ await executeSkill(ctx, deps, {
371
+ projectDirectory: metadata.projectDirectory,
372
+ skillName: metadata.skillName,
373
+ argumentsText,
374
+ });
375
+ return true;
376
+ }
@@ -1,10 +1,10 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
+ import { getGitWorktreeContext } from "../../git/worktree.js";
2
3
  import { getCurrentSession } from "../../session/manager.js";
3
4
  import { getCurrentProject, isTtsEnabled } from "../../settings/manager.js";
4
5
  import { fetchCurrentAgent } from "../../agent/manager.js";
5
6
  import { getAgentDisplayName } from "../../agent/types.js";
6
7
  import { fetchCurrentModel } from "../../model/manager.js";
7
- import { processManager } from "../../process/manager.js";
8
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
9
  import { pinnedMessageManager } from "../../pinned/manager.js";
10
10
  import { logger } from "../../utils/logger.js";
@@ -25,17 +25,6 @@ export async function statusCommand(ctx) {
25
25
  message += `${t("status.line.tts", {
26
26
  tts: isTtsEnabled() ? t("status.tts.on") : t("status.tts.off"),
27
27
  })}\n`;
28
- // Add process management information
29
- if (processManager.isRunning()) {
30
- const uptime = processManager.getUptime();
31
- const uptimeStr = uptime ? Math.floor(uptime / 1000) : 0;
32
- message += `${t("status.line.managed_yes")}\n`;
33
- message += `${t("status.line.pid", { pid: processManager.getPID() ?? "-" })}\n`;
34
- message += `${t("status.line.uptime_sec", { seconds: uptimeStr })}\n`;
35
- }
36
- else {
37
- message += `${t("status.line.managed_no")}\n`;
38
- }
39
28
  // Add agent information
40
29
  const currentAgent = await fetchCurrentAgent();
41
30
  const agentDisplay = currentAgent
@@ -48,8 +37,26 @@ export async function statusCommand(ctx) {
48
37
  message += `${t("status.line.model", { model: modelDisplay })}\n`;
49
38
  const currentProject = getCurrentProject();
50
39
  if (currentProject) {
51
- const projectName = currentProject.name || currentProject.worktree;
52
- message += `\n${t("status.project_selected", { project: projectName })}\n`;
40
+ let projectDisplay = currentProject.worktree;
41
+ let linkedWorktreePath = null;
42
+ try {
43
+ const worktreeContext = await getGitWorktreeContext(currentProject.worktree);
44
+ if (worktreeContext) {
45
+ projectDisplay = worktreeContext.branch
46
+ ? `${worktreeContext.mainProjectPath}: ${worktreeContext.branch}`
47
+ : worktreeContext.mainProjectPath;
48
+ linkedWorktreePath = worktreeContext.isLinkedWorktree
49
+ ? worktreeContext.activeWorktreePath
50
+ : null;
51
+ }
52
+ }
53
+ catch (error) {
54
+ logger.debug("[Status] Could not resolve git worktree metadata", error);
55
+ }
56
+ message += `\n${t("status.project_selected", { project: projectDisplay })}\n`;
57
+ if (linkedWorktreePath) {
58
+ message += `${t("status.worktree_selected", { worktree: linkedWorktreePath })}\n`;
59
+ }
53
60
  }
54
61
  else {
55
62
  message += `\n${t("status.project_not_selected")}\n`;
@@ -0,0 +1,196 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { config } from "../../config.js";
3
+ import { getGitWorktreeContext } from "../../git/worktree.js";
4
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
5
+ import { getProjectByWorktree } from "../../project/manager.js";
6
+ import { upsertSessionDirectory } from "../../session/cache-manager.js";
7
+ import { getCurrentProject } from "../../settings/manager.js";
8
+ import { logger } from "../../utils/logger.js";
9
+ import { t } from "../../i18n/index.js";
10
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
11
+ import { switchToProject } from "../utils/switch-project.js";
12
+ import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
13
+ import { buildProjectButtonLabel, calculateProjectsPaginationRange } from "./projects.js";
14
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
15
+ const WORKTREE_CALLBACK_PREFIX = "worktree:";
16
+ const WORKTREE_PAGE_CALLBACK_PREFIX = "worktree:page:";
17
+ function formatWorktreeButtonLabel(label, isActive) {
18
+ const prefix = isActive ? "✅ " : "";
19
+ const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
20
+ if (label.length <= availableLength) {
21
+ return `${prefix}${label}`;
22
+ }
23
+ return `${prefix}${label.slice(0, Math.max(0, availableLength - 3))}...`;
24
+ }
25
+ function buildWorktreeButtonLabel(index, entry) {
26
+ return buildProjectButtonLabel(index, entry.path);
27
+ }
28
+ function parseWorktreePageCallback(data) {
29
+ if (!data.startsWith(WORKTREE_PAGE_CALLBACK_PREFIX)) {
30
+ return null;
31
+ }
32
+ const rawPage = data.slice(WORKTREE_PAGE_CALLBACK_PREFIX.length);
33
+ if (!/^\d+$/.test(rawPage)) {
34
+ return null;
35
+ }
36
+ return Number.parseInt(rawPage, 10);
37
+ }
38
+ function parseWorktreeIndexCallback(data) {
39
+ if (!data.startsWith(WORKTREE_CALLBACK_PREFIX) ||
40
+ data.startsWith(WORKTREE_PAGE_CALLBACK_PREFIX)) {
41
+ return null;
42
+ }
43
+ const rawIndex = data.slice(WORKTREE_CALLBACK_PREFIX.length);
44
+ if (!/^\d+$/.test(rawIndex)) {
45
+ return null;
46
+ }
47
+ return Number.parseInt(rawIndex, 10);
48
+ }
49
+ function buildWorktreeMenuText(page, totalPages) {
50
+ const baseText = t("worktree.select_with_current");
51
+ if (totalPages <= 1) {
52
+ return baseText;
53
+ }
54
+ return `${baseText}\n\n${t("projects.page_indicator", {
55
+ current: String(page + 1),
56
+ total: String(totalPages),
57
+ })}`;
58
+ }
59
+ function buildWorktreeKeyboard(worktrees, page) {
60
+ const keyboard = new InlineKeyboard();
61
+ const pageSize = config.bot.projectsListLimit;
62
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(worktrees.length, page, pageSize);
63
+ worktrees.slice(startIndex, endIndex).forEach((entry, index) => {
64
+ const label = buildWorktreeButtonLabel(startIndex + index, entry);
65
+ keyboard
66
+ .text(formatWorktreeButtonLabel(label, entry.isCurrent), `${WORKTREE_CALLBACK_PREFIX}${startIndex + index}`)
67
+ .row();
68
+ });
69
+ if (totalPages > 1) {
70
+ if (normalizedPage > 0) {
71
+ keyboard.text(t("projects.prev_page"), `${WORKTREE_PAGE_CALLBACK_PREFIX}${normalizedPage - 1}`);
72
+ }
73
+ if (normalizedPage < totalPages - 1) {
74
+ keyboard.text(t("projects.next_page"), `${WORKTREE_PAGE_CALLBACK_PREFIX}${normalizedPage + 1}`);
75
+ }
76
+ }
77
+ return keyboard;
78
+ }
79
+ function buildWorktreeMenuView(worktrees, page) {
80
+ const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(worktrees.length, page, config.bot.projectsListLimit);
81
+ return {
82
+ text: buildWorktreeMenuText(normalizedPage, totalPages),
83
+ keyboard: buildWorktreeKeyboard(worktrees, normalizedPage),
84
+ };
85
+ }
86
+ async function loadCurrentWorktreeContext() {
87
+ const currentProject = getCurrentProject();
88
+ if (!currentProject) {
89
+ return { currentProject: null, context: null };
90
+ }
91
+ const context = await getGitWorktreeContext(currentProject.worktree);
92
+ return { currentProject, context };
93
+ }
94
+ export async function worktreeCommand(ctx) {
95
+ try {
96
+ if (isForegroundBusy()) {
97
+ await replyBusyBlocked(ctx);
98
+ return;
99
+ }
100
+ const { currentProject, context } = await loadCurrentWorktreeContext();
101
+ if (!currentProject) {
102
+ await ctx.reply(t("worktree.project_not_selected"));
103
+ return;
104
+ }
105
+ if (!context) {
106
+ await ctx.reply(t("worktree.not_git_repo"));
107
+ return;
108
+ }
109
+ if (context.worktrees.length === 0) {
110
+ await ctx.reply(t("worktree.empty"));
111
+ return;
112
+ }
113
+ const { text, keyboard } = buildWorktreeMenuView(context.worktrees, 0);
114
+ await replyWithInlineMenu(ctx, {
115
+ menuKind: "worktree",
116
+ text,
117
+ keyboard,
118
+ });
119
+ }
120
+ catch (error) {
121
+ logger.error("[Bot] Error loading worktrees:", error);
122
+ await ctx.reply(t("worktree.fetch_error"));
123
+ }
124
+ }
125
+ export async function handleWorktreeCallback(ctx) {
126
+ const callbackQuery = ctx.callbackQuery;
127
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith(WORKTREE_CALLBACK_PREFIX)) {
128
+ return false;
129
+ }
130
+ if (isForegroundBusy()) {
131
+ await replyBusyBlocked(ctx);
132
+ return true;
133
+ }
134
+ const page = parseWorktreePageCallback(callbackQuery.data);
135
+ const index = parseWorktreeIndexCallback(callbackQuery.data);
136
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "worktree");
137
+ if (!isActiveMenu) {
138
+ return true;
139
+ }
140
+ try {
141
+ const { currentProject, context } = await loadCurrentWorktreeContext();
142
+ if (!currentProject) {
143
+ clearAllInteractionState("worktree_project_missing");
144
+ await ctx.answerCallbackQuery();
145
+ await ctx.reply(t("worktree.project_not_selected"));
146
+ return true;
147
+ }
148
+ if (!context) {
149
+ clearAllInteractionState("worktree_git_context_missing");
150
+ await ctx.answerCallbackQuery({ text: t("worktree.not_git_repo_callback") });
151
+ return true;
152
+ }
153
+ if (page !== null) {
154
+ if (context.worktrees.length === 0) {
155
+ await ctx.answerCallbackQuery({ text: t("worktree.page_empty_callback") });
156
+ return true;
157
+ }
158
+ const { text, keyboard } = buildWorktreeMenuView(context.worktrees, page);
159
+ await ctx.answerCallbackQuery();
160
+ await ctx.editMessageText(text, {
161
+ reply_markup: appendInlineMenuCancelButton(keyboard, "worktree"),
162
+ });
163
+ return true;
164
+ }
165
+ if (index === null) {
166
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
167
+ return true;
168
+ }
169
+ const selectedWorktree = context.worktrees[index];
170
+ if (!selectedWorktree) {
171
+ await ctx.answerCallbackQuery({ text: t("worktree.selection_missing_callback") });
172
+ return true;
173
+ }
174
+ if (selectedWorktree.isCurrent) {
175
+ await ctx.answerCallbackQuery({ text: t("worktree.already_selected_callback") });
176
+ return true;
177
+ }
178
+ logger.info(`[Bot] Worktree selected: ${selectedWorktree.path}`);
179
+ await upsertSessionDirectory(selectedWorktree.path, Date.now());
180
+ const projectInfo = await getProjectByWorktree(selectedWorktree.path);
181
+ const replyKeyboard = await switchToProject(ctx, { ...projectInfo, name: selectedWorktree.path }, "worktree_switched");
182
+ await ctx.answerCallbackQuery();
183
+ await ctx.reply(t("worktree.selected", { worktree: selectedWorktree.path }), {
184
+ reply_markup: replyKeyboard,
185
+ });
186
+ await ctx.deleteMessage();
187
+ return true;
188
+ }
189
+ catch (error) {
190
+ logger.error("[Bot] Error handling worktree callback:", error);
191
+ clearAllInteractionState("worktree_select_error");
192
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
193
+ await ctx.reply(t("worktree.select_error"));
194
+ return true;
195
+ }
196
+ }
@@ -11,6 +11,7 @@ const INLINE_MENU_KINDS = [
11
11
  "variant",
12
12
  "context",
13
13
  "open",
14
+ "worktree",
14
15
  ];
15
16
  function isInlineMenuKind(value) {
16
17
  return INLINE_MENU_KINDS.includes(value);
@@ -156,8 +156,15 @@ export async function handleVoiceMessage(ctx, deps) {
156
156
  logger.warn("[Voice] Failed to edit status message with recognized text:", editError);
157
157
  }
158
158
  logger.info(`[Voice] Transcribed audio: ${recognizedText.length} chars`);
159
+ let textForLLM = recognizedText;
160
+ const notePrompt = config.stt.notePrompt.trim();
161
+ if (notePrompt && notePrompt.toLowerCase() !== "false" && notePrompt !== "0") {
162
+ const llmNote = `[Note: ${notePrompt}]`;
163
+ logger.debug(`[Voice] Added STT note to LLM prompt: ${llmNote}`);
164
+ textForLLM = `${llmNote}\n${recognizedText}`;
165
+ }
159
166
  // Process the recognized text as a prompt
160
- await processPrompt(ctx, recognizedText, deps);
167
+ await processPrompt(ctx, textForLLM, deps);
161
168
  }
162
169
  catch (err) {
163
170
  const errorMessage = err instanceof Error ? err.message : "unknown error";