@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.
@@ -3,35 +3,8 @@ import path from "node:path";
3
3
  import { opencodeClient } from "../opencode/client.js";
4
4
  import { getCachedSessionProjects } from "../session/cache-manager.js";
5
5
  import { logger } from "../utils/logger.js";
6
- async function isLinkedGitWorktree(worktree) {
7
- if (worktree === "/") {
8
- return false;
9
- }
10
- const gitPath = path.join(worktree, ".git");
11
- try {
12
- const gitStat = await stat(gitPath);
13
- if (!gitStat.isFile()) {
14
- return false;
15
- }
16
- const gitPointer = (await readFile(gitPath, "utf-8")).trim();
17
- const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
18
- if (!match) {
19
- return false;
20
- }
21
- const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
22
- return gitDir.includes("/.git/worktrees/");
23
- }
24
- catch {
25
- return false;
26
- }
27
- }
28
- function worktreeKey(worktree) {
29
- if (process.platform === "win32") {
30
- return worktree.toLowerCase();
31
- }
32
- return worktree;
33
- }
34
- export async function getProjects() {
6
+ async function getResolvedProjects(options) {
7
+ const includeLinkedWorktrees = options?.includeLinkedWorktrees === true;
35
8
  const { data: projects, error } = await opencodeClient.project.list();
36
9
  if (error || !projects) {
37
10
  throw error || new Error("No data received from server");
@@ -64,11 +37,53 @@ export async function getProjects() {
64
37
  });
65
38
  }
66
39
  const projectList = Array.from(mergedByWorktree.values()).sort((left, right) => right.lastUpdated - left.lastUpdated);
40
+ if (includeLinkedWorktrees) {
41
+ return projectList;
42
+ }
67
43
  const linkedWorktreeFlags = await Promise.all(projectList.map((project) => isLinkedGitWorktree(project.worktree)));
68
44
  const visibleProjects = projectList.filter((_, index) => !linkedWorktreeFlags[index]);
69
45
  const hiddenLinkedWorktrees = projectList.length - visibleProjects.length;
70
46
  logger.debug(`[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, total=${visibleProjects.length}`);
71
- return visibleProjects.map(({ id, worktree, name }) => ({ id, worktree, name }));
47
+ return visibleProjects;
48
+ }
49
+ async function isLinkedGitWorktree(worktree) {
50
+ if (worktree === "/") {
51
+ return false;
52
+ }
53
+ const gitPath = path.join(worktree, ".git");
54
+ try {
55
+ const gitStat = await stat(gitPath);
56
+ if (!gitStat.isFile()) {
57
+ return false;
58
+ }
59
+ const gitPointer = (await readFile(gitPath, "utf-8")).trim();
60
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
61
+ if (!match) {
62
+ return false;
63
+ }
64
+ const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
65
+ return gitDir.includes("/.git/worktrees/");
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function worktreeKey(worktree) {
72
+ const pathModule = isWindowsWorktreePath(worktree) ? path.win32 : path.posix;
73
+ const normalizedWorktree = pathModule.normalize(worktree);
74
+ const root = pathModule.parse(normalizedWorktree).root;
75
+ const trimmedWorktree = normalizedWorktree === root ? normalizedWorktree : normalizedWorktree.replace(/[\\/]+$/, "");
76
+ if (pathModule === path.win32) {
77
+ return trimmedWorktree.toLowerCase();
78
+ }
79
+ return trimmedWorktree;
80
+ }
81
+ function isWindowsWorktreePath(worktree) {
82
+ return process.platform === "win32" || /^[a-zA-Z]:[\\/]/.test(worktree) || /^\\\\/.test(worktree);
83
+ }
84
+ export async function getProjects() {
85
+ const projects = await getResolvedProjects();
86
+ return projects.map(({ id, worktree, name }) => ({ id, worktree, name }));
72
87
  }
73
88
  export async function getProjectById(id) {
74
89
  const projects = await getProjects();
@@ -79,11 +94,11 @@ export async function getProjectById(id) {
79
94
  return project;
80
95
  }
81
96
  export async function getProjectByWorktree(worktree) {
82
- const projects = await getProjects();
97
+ const projects = await getResolvedProjects({ includeLinkedWorktrees: true });
83
98
  const key = worktreeKey(worktree);
84
99
  const project = projects.find((p) => worktreeKey(p.worktree) === key);
85
100
  if (!project) {
86
101
  throw new Error(`Project with worktree ${worktree} not found`);
87
102
  }
88
- return project;
103
+ return { id: project.id, worktree: project.worktree, name: project.name };
89
104
  }
@@ -10,6 +10,16 @@ const DEFAULT_API_URL = "http://localhost:4096";
10
10
  const DEFAULT_SERVER_USERNAME = "opencode";
11
11
  const FALLBACK_MODEL_PROVIDER = "opencode";
12
12
  const FALLBACK_MODEL_ID = "big-pickle";
13
+ const WIZARD_ENV_KEYS = [
14
+ "BOT_LOCALE",
15
+ "TELEGRAM_BOT_TOKEN",
16
+ "TELEGRAM_ALLOWED_USER_ID",
17
+ "OPENCODE_API_URL",
18
+ "OPENCODE_SERVER_USERNAME",
19
+ "OPENCODE_SERVER_PASSWORD",
20
+ "OPENCODE_MODEL_PROVIDER",
21
+ "OPENCODE_MODEL_ID",
22
+ ];
13
23
  function isPositiveInteger(value) {
14
24
  return /^[1-9]\d*$/.test(value);
15
25
  }
@@ -55,10 +65,63 @@ function removeEnvKey(lines, key) {
55
65
  const regex = new RegExp(`^\\s*(?:export\\s+)?${escapeRegex(key)}\\s*=`);
56
66
  return lines.filter((line) => !regex.test(line));
57
67
  }
68
+ function parseEnvAssignmentLine(line) {
69
+ const match = /^(\s*#\s*)?(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
70
+ if (!match) {
71
+ return null;
72
+ }
73
+ return {
74
+ key: match[2],
75
+ rawValue: match[3],
76
+ line,
77
+ isCommented: typeof match[1] === "string",
78
+ };
79
+ }
80
+ function buildTemplateKeySet(templateContent) {
81
+ const keys = new Set();
82
+ for (const line of normalizeEnvLineEndings(templateContent)) {
83
+ const parsedLine = parseEnvAssignmentLine(line);
84
+ if (parsedLine !== null) {
85
+ keys.add(parsedLine.key);
86
+ }
87
+ }
88
+ return keys;
89
+ }
90
+ function collectActiveEnvAssignments(content) {
91
+ const assignments = new Map();
92
+ for (const line of normalizeEnvLineEndings(content)) {
93
+ const parsedLine = parseEnvAssignmentLine(line);
94
+ if (parsedLine === null || parsedLine.isCommented) {
95
+ continue;
96
+ }
97
+ assignments.set(parsedLine.key, parsedLine);
98
+ }
99
+ return assignments;
100
+ }
101
+ function collectCustomEnvAssignments(existingContent, templateKeys) {
102
+ const customAssignments = [];
103
+ const seenKeys = new Set();
104
+ const lines = normalizeEnvLineEndings(existingContent);
105
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
106
+ const parsedLine = parseEnvAssignmentLine(lines[index]);
107
+ if (parsedLine === null || parsedLine.isCommented || templateKeys.has(parsedLine.key)) {
108
+ continue;
109
+ }
110
+ if (seenKeys.has(parsedLine.key)) {
111
+ continue;
112
+ }
113
+ seenKeys.add(parsedLine.key);
114
+ customAssignments.push(parsedLine);
115
+ }
116
+ return customAssignments.reverse().map((assignment) => assignment.line);
117
+ }
118
+ function renderEnvAssignment(key, rawValue) {
119
+ return `${key}=${rawValue}`;
120
+ }
58
121
  function finalizeEnvContent(lines) {
59
122
  return `${lines.join("\n")}\n`;
60
123
  }
61
- export function buildEnvFileContent(existingContent, values) {
124
+ function buildFlatEnvFileContent(existingContent, values) {
62
125
  let lines = normalizeEnvLineEndings(existingContent);
63
126
  const orderedUpdates = [
64
127
  ["BOT_LOCALE", values.BOT_LOCALE],
@@ -78,6 +141,44 @@ export function buildEnvFileContent(existingContent, values) {
78
141
  }
79
142
  return finalizeEnvContent(lines);
80
143
  }
144
+ export function buildEnvFileContent(existingContent, values, envExampleContent) {
145
+ if (!envExampleContent) {
146
+ return buildFlatEnvFileContent(existingContent, values);
147
+ }
148
+ const templateLines = normalizeEnvLineEndings(envExampleContent);
149
+ if (templateLines.length === 0) {
150
+ return buildFlatEnvFileContent(existingContent, values);
151
+ }
152
+ const templateKeys = buildTemplateKeySet(envExampleContent);
153
+ const existingAssignments = collectActiveEnvAssignments(existingContent);
154
+ const wizardOverrides = new Map(WIZARD_ENV_KEYS.map((key) => [key, values[key]]));
155
+ const renderedLines = templateLines.map((line) => {
156
+ const parsedLine = parseEnvAssignmentLine(line);
157
+ if (parsedLine === null) {
158
+ return line;
159
+ }
160
+ if (wizardOverrides.has(parsedLine.key)) {
161
+ const overrideValue = wizardOverrides.get(parsedLine.key);
162
+ if (overrideValue && overrideValue.trim().length > 0) {
163
+ return renderEnvAssignment(parsedLine.key, overrideValue);
164
+ }
165
+ return line;
166
+ }
167
+ const existingAssignment = existingAssignments.get(parsedLine.key);
168
+ if (existingAssignment !== undefined) {
169
+ return renderEnvAssignment(parsedLine.key, existingAssignment.rawValue);
170
+ }
171
+ return line;
172
+ });
173
+ const customAssignments = collectCustomEnvAssignments(existingContent, templateKeys);
174
+ if (customAssignments.length > 0) {
175
+ if (renderedLines.length > 0 && renderedLines[renderedLines.length - 1] !== "") {
176
+ renderedLines.push("");
177
+ }
178
+ renderedLines.push(...customAssignments);
179
+ }
180
+ return finalizeEnvContent(renderedLines);
181
+ }
81
182
  async function readEnvFileIfExists(filePath) {
82
183
  try {
83
184
  return await fs.readFile(filePath, "utf-8");
@@ -112,14 +213,24 @@ function getEnvExamplePath() {
112
213
  const currentFilePath = fileURLToPath(import.meta.url);
113
214
  return path.resolve(path.dirname(currentFilePath), "..", "..", ".env.example");
114
215
  }
115
- async function loadModelDefaultsFromEnvExample() {
216
+ async function loadEnvExampleContent() {
217
+ try {
218
+ return await fs.readFile(getEnvExamplePath(), "utf-8");
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ }
224
+ function loadModelDefaultsFromEnvExample(envExampleContent) {
116
225
  const fallbackDefaults = {
117
226
  provider: FALLBACK_MODEL_PROVIDER,
118
227
  modelId: FALLBACK_MODEL_ID,
119
228
  };
120
229
  try {
121
- const content = await fs.readFile(getEnvExamplePath(), "utf-8");
122
- const parsed = dotenv.parse(content);
230
+ if (!envExampleContent) {
231
+ return fallbackDefaults;
232
+ }
233
+ const parsed = dotenv.parse(envExampleContent);
123
234
  const provider = parsed.OPENCODE_MODEL_PROVIDER?.trim();
124
235
  const modelId = parsed.OPENCODE_MODEL_ID?.trim();
125
236
  if (!provider || !modelId) {
@@ -305,11 +416,12 @@ async function validateExistingEnv(envFilePath) {
305
416
  }
306
417
  async function runWizardAndPersist(runtimePaths) {
307
418
  ensureInteractiveTty();
308
- const [existingContent, modelDefaults, wizardValues] = await Promise.all([
419
+ const [existingContent, envExampleContent, wizardValues] = await Promise.all([
309
420
  readEnvFileIfExists(runtimePaths.envFilePath),
310
- loadModelDefaultsFromEnvExample(),
421
+ loadEnvExampleContent(),
311
422
  collectWizardValues(),
312
423
  ]);
424
+ const modelDefaults = loadModelDefaultsFromEnvExample(envExampleContent);
313
425
  const existingParsed = existingContent ? dotenv.parse(existingContent) : {};
314
426
  const provider = existingParsed.OPENCODE_MODEL_PROVIDER || modelDefaults.provider;
315
427
  const modelId = existingParsed.OPENCODE_MODEL_ID || modelDefaults.modelId;
@@ -323,7 +435,7 @@ async function runWizardAndPersist(runtimePaths) {
323
435
  OPENCODE_MODEL_PROVIDER: provider,
324
436
  OPENCODE_MODEL_ID: modelId,
325
437
  };
326
- const envContent = buildEnvFileContent(existingContent ?? "", envValues);
438
+ const envContent = buildEnvFileContent(existingContent ?? "", envValues, envExampleContent);
327
439
  await writeFileAtomically(runtimePaths.envFilePath, envContent);
328
440
  await ensureSettingsFile(runtimePaths.settingsFilePath);
329
441
  process.stdout.write(t("runtime.wizard.saved", {
@@ -1,7 +1,12 @@
1
+ import { config } from "../config.js";
1
2
  import { opencodeClient } from "../opencode/client.js";
2
3
  import { logger } from "../utils/logger.js";
3
4
  const SCHEDULED_TASK_AGENT = "build";
4
5
  const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
6
+ const EXECUTION_POLL_INTERVAL_MS = 2000;
7
+ const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
8
+ const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
9
+ const EXECUTION_TIMEOUT_ERROR_PREFIX = "Scheduled task exceeded bot execution timeout";
5
10
  function collectResponseText(parts) {
6
11
  return parts
7
12
  .filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
@@ -9,15 +14,143 @@ function collectResponseText(parts) {
9
14
  .join("")
10
15
  .trim();
11
16
  }
12
- function toErrorMessage(error) {
17
+ function extractErrorMessage(error) {
13
18
  if (error instanceof Error && error.message.trim()) {
14
19
  return error.message.trim();
15
20
  }
16
21
  if (typeof error === "string" && error.trim()) {
17
22
  return error.trim();
18
23
  }
24
+ if (!error || typeof error !== "object") {
25
+ return null;
26
+ }
27
+ const typedError = error;
28
+ if (typeof typedError.data?.message === "string" && typedError.data.message.trim()) {
29
+ return typedError.data.message.trim();
30
+ }
31
+ if (typeof typedError.message === "string" && typedError.message.trim()) {
32
+ return typedError.message.trim();
33
+ }
34
+ if (typeof typedError.name === "string" && typedError.name.trim()) {
35
+ return typedError.name.trim();
36
+ }
37
+ return null;
38
+ }
39
+ function isTimeoutErrorMessage(message) {
40
+ return /(timed out|timeout|time out|deadline exceeded|request aborted)/i.test(message);
41
+ }
42
+ function isBotExecutionTimeoutMessage(message) {
43
+ return message.startsWith(EXECUTION_TIMEOUT_ERROR_PREFIX);
44
+ }
45
+ function createExecutionTimeoutMessage() {
46
+ return `${EXECUTION_TIMEOUT_ERROR_PREFIX} after ${config.bot.scheduledTaskExecutionTimeoutMinutes} minutes.`;
47
+ }
48
+ function getExecutionTimeoutMs() {
49
+ return config.bot.scheduledTaskExecutionTimeoutMinutes * 60 * 1000;
50
+ }
51
+ function normalizeScheduledTaskErrorMessage(message) {
52
+ if (isBotExecutionTimeoutMessage(message) ||
53
+ !isTimeoutErrorMessage(message) ||
54
+ message.includes(MODELS_DOCS_URL)) {
55
+ return message;
56
+ }
57
+ return `${message} Check OpenCode model timeout settings: ${MODELS_DOCS_URL}`;
58
+ }
59
+ function toErrorMessage(error) {
60
+ const message = extractErrorMessage(error);
61
+ if (message) {
62
+ return normalizeScheduledTaskErrorMessage(message);
63
+ }
19
64
  return "Unknown scheduled task execution error";
20
65
  }
66
+ function sleep(ms) {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ function findLatestAssistantMessage(messages) {
70
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
71
+ const message = messages[index];
72
+ if (message?.info.role === "assistant") {
73
+ return message;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ function extractAssistantResult(message) {
79
+ if (!message) {
80
+ return { resultText: null, errorMessage: null, completed: false };
81
+ }
82
+ const errorMessage = extractErrorMessage(message.info.error);
83
+ if (errorMessage) {
84
+ return {
85
+ resultText: null,
86
+ errorMessage: normalizeScheduledTaskErrorMessage(errorMessage),
87
+ completed: true,
88
+ };
89
+ }
90
+ const resultText = collectResponseText(message.parts);
91
+ return {
92
+ resultText,
93
+ errorMessage: null,
94
+ completed: Boolean(message.info.time?.completed),
95
+ };
96
+ }
97
+ async function loadAssistantResult(sessionId, directory) {
98
+ const { data: messages, error: messagesError } = await opencodeClient.session.messages({
99
+ sessionID: sessionId,
100
+ directory,
101
+ });
102
+ if (messagesError || !messages) {
103
+ throw messagesError || new Error("Failed to load scheduled task messages");
104
+ }
105
+ return extractAssistantResult(findLatestAssistantMessage(messages));
106
+ }
107
+ async function waitForScheduledTaskResult(sessionId, directory) {
108
+ const startedAtMs = Date.now();
109
+ const executionTimeoutMs = getExecutionTimeoutMs();
110
+ let idlePollsWithoutResult = 0;
111
+ while (true) {
112
+ if (Date.now() - startedAtMs >= executionTimeoutMs) {
113
+ throw new Error(createExecutionTimeoutMessage());
114
+ }
115
+ const assistantResult = await loadAssistantResult(sessionId, directory);
116
+ if (assistantResult.errorMessage) {
117
+ throw new Error(assistantResult.errorMessage);
118
+ }
119
+ if (assistantResult.completed) {
120
+ if (assistantResult.resultText) {
121
+ return assistantResult.resultText;
122
+ }
123
+ throw new Error("Scheduled task returned an empty assistant response");
124
+ }
125
+ const { data: statuses, error: statusError } = await opencodeClient.session.status({
126
+ directory,
127
+ });
128
+ if (statusError || !statuses) {
129
+ throw statusError || new Error("Failed to load scheduled task status");
130
+ }
131
+ const sessionStatus = statuses[sessionId];
132
+ if (!sessionStatus || sessionStatus.type === "idle") {
133
+ const confirmedAssistantResult = await loadAssistantResult(sessionId, directory);
134
+ if (confirmedAssistantResult.errorMessage) {
135
+ throw new Error(confirmedAssistantResult.errorMessage);
136
+ }
137
+ if (confirmedAssistantResult.completed) {
138
+ if (confirmedAssistantResult.resultText) {
139
+ return confirmedAssistantResult.resultText;
140
+ }
141
+ throw new Error("Scheduled task returned an empty assistant response");
142
+ }
143
+ idlePollsWithoutResult += 1;
144
+ if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
145
+ throw new Error("Scheduled task finished without a completed assistant response");
146
+ }
147
+ }
148
+ else {
149
+ idlePollsWithoutResult = 0;
150
+ }
151
+ await sleep(EXECUTION_POLL_INTERVAL_MS);
152
+ }
153
+ }
21
154
  export async function executeScheduledTask(task) {
22
155
  const startedAt = new Date().toISOString();
23
156
  let sessionId = null;
@@ -45,14 +178,11 @@ export async function executeScheduledTask(task) {
45
178
  if (task.model.variant) {
46
179
  promptOptions.variant = task.model.variant;
47
180
  }
48
- const { data: response, error: promptError } = await opencodeClient.session.prompt(promptOptions);
49
- if (promptError || !response) {
181
+ const { error: promptError } = await opencodeClient.session.promptAsync(promptOptions);
182
+ if (promptError) {
50
183
  throw promptError || new Error("Scheduled task prompt execution failed");
51
184
  }
52
- const resultText = collectResponseText(response.parts);
53
- if (!resultText) {
54
- throw new Error("Scheduled task returned an empty assistant response");
55
- }
185
+ const resultText = await waitForScheduledTaskResult(session.id, session.directory);
56
186
  return {
57
187
  taskId: task.id,
58
188
  status: "success",
@@ -103,17 +103,6 @@ export function clearPinnedMessageId() {
103
103
  currentSettings.pinnedMessageId = undefined;
104
104
  void writeSettingsFile(currentSettings);
105
105
  }
106
- export function getServerProcess() {
107
- return currentSettings.serverProcess;
108
- }
109
- export function setServerProcess(processInfo) {
110
- currentSettings.serverProcess = processInfo;
111
- void writeSettingsFile(currentSettings);
112
- }
113
- export function clearServerProcess() {
114
- currentSettings.serverProcess = undefined;
115
- void writeSettingsFile(currentSettings);
116
- }
117
106
  export function getSessionDirectoryCache() {
118
107
  return currentSettings.sessionDirectoryCache;
119
108
  }
@@ -138,10 +127,18 @@ export function __resetSettingsForTests() {
138
127
  }
139
128
  export async function loadSettings() {
140
129
  const loadedSettings = (await readSettingsFile());
130
+ let requiresRewrite = false;
141
131
  if ("toolMessagesIntervalSec" in loadedSettings) {
142
132
  delete loadedSettings.toolMessagesIntervalSec;
143
- void writeSettingsFile(loadedSettings);
133
+ requiresRewrite = true;
134
+ }
135
+ if ("serverProcess" in loadedSettings) {
136
+ delete loadedSettings.serverProcess;
137
+ requiresRewrite = true;
144
138
  }
145
139
  currentSettings = loadedSettings;
146
140
  currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
141
+ if (requiresRewrite) {
142
+ void writeSettingsFile(currentSettings);
143
+ }
147
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",