@grinev/opencode-telegram-bot 0.16.0 → 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.
@@ -1,5 +1,6 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { getCurrentProject } from "../../settings/manager.js";
3
+ import { getGitWorktreeContext } from "../../git/worktree.js";
3
4
  import { getProjects } from "../../project/manager.js";
4
5
  import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
5
6
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
@@ -68,14 +69,37 @@ function buildProjectsMenuText(currentProjectName, page, totalPages) {
68
69
  total: String(totalPages),
69
70
  })}`;
70
71
  }
71
- function buildProjectsKeyboard(projects, page) {
72
+ function worktreeKey(worktree) {
73
+ return process.platform === "win32" ? worktree.toLowerCase() : worktree;
74
+ }
75
+ async function getActiveProjectWorktree() {
76
+ const currentProject = getCurrentProject();
77
+ if (!currentProject) {
78
+ return null;
79
+ }
80
+ try {
81
+ const worktreeContext = await getGitWorktreeContext(currentProject.worktree);
82
+ if (worktreeContext) {
83
+ return worktreeContext.mainProjectPath;
84
+ }
85
+ }
86
+ catch (error) {
87
+ logger.debug("[Projects] Could not resolve active git worktree metadata:", error);
88
+ }
89
+ return currentProject.worktree;
90
+ }
91
+ async function buildProjectsKeyboard(projects, page) {
72
92
  const keyboard = new InlineKeyboard();
73
93
  const currentProject = getCurrentProject();
94
+ const activeProjectWorktree = await getActiveProjectWorktree();
74
95
  const pageSize = config.bot.projectsListLimit;
75
96
  const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(projects.length, page, pageSize);
76
97
  projects.slice(startIndex, endIndex).forEach((project, index) => {
77
98
  const isActive = currentProject &&
78
- (project.id === currentProject.id || project.worktree === currentProject.worktree);
99
+ (project.id === currentProject.id ||
100
+ project.worktree === currentProject.worktree ||
101
+ (activeProjectWorktree !== null &&
102
+ worktreeKey(project.worktree) === worktreeKey(activeProjectWorktree)));
79
103
  const label = buildProjectButtonLabel(startIndex + index, project.worktree);
80
104
  const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
81
105
  keyboard.text(labelWithCheck, `project:${project.id}`).row();
@@ -90,14 +114,14 @@ function buildProjectsKeyboard(projects, page) {
90
114
  }
91
115
  return keyboard;
92
116
  }
93
- function buildProjectsMenuView(projects, page) {
117
+ async function buildProjectsMenuView(projects, page) {
94
118
  const currentProject = getCurrentProject();
95
119
  const pageSize = config.bot.projectsListLimit;
96
120
  const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(projects.length, page, pageSize);
97
121
  const currentProjectName = currentProject?.name || currentProject?.worktree || null;
98
122
  return {
99
123
  text: buildProjectsMenuText(currentProjectName, normalizedPage, totalPages),
100
- keyboard: buildProjectsKeyboard(projects, normalizedPage),
124
+ keyboard: await buildProjectsKeyboard(projects, normalizedPage),
101
125
  };
102
126
  }
103
127
  export async function projectsCommand(ctx) {
@@ -112,7 +136,7 @@ export async function projectsCommand(ctx) {
112
136
  await ctx.reply(t("projects.empty"));
113
137
  return;
114
138
  }
115
- const { text, keyboard } = buildProjectsMenuView(projects, 0);
139
+ const { text, keyboard } = await buildProjectsMenuView(projects, 0);
116
140
  await replyWithInlineMenu(ctx, {
117
141
  menuKind: "project",
118
142
  text,
@@ -146,7 +170,7 @@ export async function handleProjectSelect(ctx) {
146
170
  await ctx.reply(t("projects.empty"));
147
171
  return true;
148
172
  }
149
- const { text, keyboard } = buildProjectsMenuView(projects, page);
173
+ const { text, keyboard } = await buildProjectsMenuView(projects, page);
150
174
  await ctx.answerCallbackQuery();
151
175
  await ctx.editMessageText(text, {
152
176
  reply_markup: appendInlineMenuCancelButton(keyboard, "project"),
@@ -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`;