@grinev/opencode-telegram-bot 0.19.2 → 0.20.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.
Files changed (40) hide show
  1. package/.env.example +5 -0
  2. package/README.md +5 -0
  3. package/dist/app/start-bot-app.js +4 -5
  4. package/dist/attach/service.js +34 -8
  5. package/dist/background-session/tracker.js +137 -0
  6. package/dist/bot/commands/definitions.js +2 -0
  7. package/dist/bot/commands/detach.js +54 -0
  8. package/dist/bot/commands/ls.js +445 -0
  9. package/dist/bot/commands/open.js +9 -4
  10. package/dist/bot/commands/opencode-start.js +3 -3
  11. package/dist/bot/commands/projects.js +6 -2
  12. package/dist/bot/commands/sessions.js +233 -82
  13. package/dist/bot/commands/worktree.js +7 -2
  14. package/dist/bot/handlers/inline-menu.js +1 -0
  15. package/dist/bot/index.js +86 -16
  16. package/dist/bot/utils/external-user-input.js +10 -2
  17. package/dist/bot/utils/send-downloaded-file.js +57 -0
  18. package/dist/bot/utils/switch-project.js +9 -14
  19. package/dist/config.js +1 -0
  20. package/dist/i18n/de.js +26 -0
  21. package/dist/i18n/en.js +26 -0
  22. package/dist/i18n/es.js +26 -0
  23. package/dist/i18n/fr.js +26 -0
  24. package/dist/i18n/ru.js +26 -0
  25. package/dist/i18n/zh.js +26 -0
  26. package/dist/interaction/busy.js +1 -1
  27. package/dist/interaction/manager.js +1 -1
  28. package/dist/model/context-limit.js +13 -2
  29. package/dist/opencode/auto-restart.js +4 -3
  30. package/dist/opencode/events.js +13 -2
  31. package/dist/opencode/ready-lifecycle.js +45 -0
  32. package/dist/opencode/ready-refresh.js +19 -1
  33. package/dist/pinned/manager.js +43 -7
  34. package/dist/scheduled-task/executor.js +3 -0
  35. package/dist/scheduled-task/runtime.js +2 -0
  36. package/dist/scheduled-task/schedule-parser.js +6 -0
  37. package/dist/scheduled-task/session-ignore.js +53 -0
  38. package/dist/settings/manager.js +12 -0
  39. package/dist/utils/opencode-error.js +13 -0
  40. package/package.json +1 -1
@@ -0,0 +1,45 @@
1
+ import { logger } from "../utils/logger.js";
2
+ class OpencodeReadyLifecycle {
3
+ ready = false;
4
+ handlers = new Set();
5
+ onReady(handler) {
6
+ this.handlers.add(handler);
7
+ return () => {
8
+ this.handlers.delete(handler);
9
+ };
10
+ }
11
+ isReady() {
12
+ return this.ready;
13
+ }
14
+ async notifyReady(reason) {
15
+ if (this.ready) {
16
+ logger.debug(`[OpenCodeReady] Ready notification ignored: reason=${reason}`);
17
+ return false;
18
+ }
19
+ this.ready = true;
20
+ logger.info(`[OpenCodeReady] OpenCode server is ready: reason=${reason}`);
21
+ for (const handler of this.handlers) {
22
+ try {
23
+ await handler(reason);
24
+ }
25
+ catch (error) {
26
+ logger.warn(`[OpenCodeReady] Ready handler failed: reason=${reason}`, error);
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+ notifyUnavailable(reason) {
32
+ if (!this.ready) {
33
+ logger.debug(`[OpenCodeReady] Unavailable notification ignored: reason=${reason}`);
34
+ return false;
35
+ }
36
+ this.ready = false;
37
+ logger.warn(`[OpenCodeReady] OpenCode server became unavailable: reason=${reason}`);
38
+ return true;
39
+ }
40
+ __resetForTests() {
41
+ this.ready = false;
42
+ this.handlers.clear();
43
+ }
44
+ }
45
+ export const opencodeReadyLifecycle = new OpencodeReadyLifecycle();
@@ -2,7 +2,9 @@ import { reconcileStoredModelSelection } from "../model/manager.js";
2
2
  import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { opencodeClient } from "./client.js";
5
- async function isOpencodeServerHealthy() {
5
+ import { opencodeReadyLifecycle } from "./ready-lifecycle.js";
6
+ let readyRefreshRegistered = false;
7
+ export async function isOpencodeServerHealthy() {
6
8
  try {
7
9
  const { data, error } = await opencodeClient.global.health();
8
10
  return !error && data?.healthy === true;
@@ -29,9 +31,25 @@ export async function refreshSessionCacheAfterOpencodeReady(reason) {
29
31
  }
30
32
  export async function refreshSessionCacheIfOpencodeReady(reason) {
31
33
  if (!(await isOpencodeServerHealthy())) {
34
+ opencodeReadyLifecycle.notifyUnavailable(reason);
32
35
  logger.warn(`[OpenCodeReady] OpenCode server is not running; skipping session cache refresh: reason=${reason}`);
33
36
  return false;
34
37
  }
35
38
  await refreshSessionCacheAfterOpencodeReady(reason);
36
39
  return true;
37
40
  }
41
+ export function registerOpenCodeReadyRefreshHandler() {
42
+ if (readyRefreshRegistered) {
43
+ return;
44
+ }
45
+ readyRefreshRegistered = true;
46
+ opencodeReadyLifecycle.onReady((reason) => refreshSessionCacheAfterOpencodeReady(reason));
47
+ }
48
+ export async function notifyOpencodeReadyIfHealthy(reason) {
49
+ if (!(await isOpencodeServerHealthy())) {
50
+ opencodeReadyLifecycle.notifyUnavailable(reason);
51
+ logger.warn(`[OpenCodeReady] OpenCode server is not running: reason=${reason}`);
52
+ return false;
53
+ }
54
+ return opencodeReadyLifecycle.notifyReady(reason);
55
+ }
@@ -5,6 +5,7 @@ import { getCurrentSession } from "../session/manager.js";
5
5
  import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
6
6
  import { getStoredModel } from "../model/manager.js";
7
7
  import { getModelContextLimit } from "../model/context-limit.js";
8
+ import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
8
9
  import { t } from "../i18n/index.js";
9
10
  import { DEFAULT_CONTEXT_LIMIT, formatContextLine, formatCostLine, formatModelDisplayName, } from "./format.js";
10
11
  class PinnedMessageManager {
@@ -128,7 +129,12 @@ class PinnedMessageManager {
128
129
  directory,
129
130
  });
130
131
  if (error || !messagesData) {
131
- logger.warn("[PinnedManager] Failed to load session history:", error);
132
+ if (isExpectedOpencodeUnavailableError(error)) {
133
+ logger.warn("[PinnedManager] OpenCode server unavailable; skipping session history load");
134
+ }
135
+ else {
136
+ logger.warn("[PinnedManager] Failed to load session history:", error);
137
+ }
132
138
  return;
133
139
  }
134
140
  // Get the maximum context size and total cost from session history
@@ -164,7 +170,12 @@ class PinnedMessageManager {
164
170
  await this.updatePinnedMessage();
165
171
  }
166
172
  catch (err) {
167
- logger.error("[PinnedManager] Error loading context from history:", err);
173
+ if (isExpectedOpencodeUnavailableError(err)) {
174
+ logger.warn("[PinnedManager] OpenCode server unavailable; skipping session history load");
175
+ }
176
+ else {
177
+ logger.error("[PinnedManager] Error loading context from history:", err);
178
+ }
168
179
  }
169
180
  }
170
181
  /**
@@ -337,7 +348,12 @@ class PinnedMessageManager {
337
348
  await this.loadDiffsFromMessages(sessionId, project.worktree);
338
349
  }
339
350
  catch (err) {
340
- logger.debug("[PinnedManager] Could not load diffs from API:", err);
351
+ if (isExpectedOpencodeUnavailableError(err)) {
352
+ logger.debug("[PinnedManager] OpenCode server unavailable; skipping diff restore");
353
+ }
354
+ else {
355
+ logger.debug("[PinnedManager] Could not load diffs from API:", err);
356
+ }
341
357
  }
342
358
  }
343
359
  /**
@@ -351,7 +367,12 @@ class PinnedMessageManager {
351
367
  directory,
352
368
  });
353
369
  if (error || !messagesData) {
354
- logger.debug(`[PinnedManager] loadDiffsFromMessages: error or no data`);
370
+ if (isExpectedOpencodeUnavailableError(error)) {
371
+ logger.debug("[PinnedManager] OpenCode server unavailable; skipping diff message restore");
372
+ }
373
+ else {
374
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: error or no data`);
375
+ }
355
376
  return;
356
377
  }
357
378
  logger.debug(`[PinnedManager] loadDiffsFromMessages: ${messagesData.length} messages`);
@@ -422,7 +443,12 @@ class PinnedMessageManager {
422
443
  }
423
444
  }
424
445
  catch (err) {
425
- logger.debug("[PinnedManager] Could not load diffs from messages:", err);
446
+ if (isExpectedOpencodeUnavailableError(err)) {
447
+ logger.debug("[PinnedManager] OpenCode server unavailable; skipping diff message restore");
448
+ }
449
+ else {
450
+ logger.debug("[PinnedManager] Could not load diffs from messages:", err);
451
+ }
426
452
  }
427
453
  }
428
454
  /**
@@ -445,7 +471,12 @@ class PinnedMessageManager {
445
471
  }
446
472
  }
447
473
  catch (err) {
448
- logger.debug("[PinnedManager] Could not refresh session title:", err);
474
+ if (isExpectedOpencodeUnavailableError(err)) {
475
+ logger.debug("[PinnedManager] OpenCode server unavailable; skipping session title refresh");
476
+ }
477
+ else {
478
+ logger.debug("[PinnedManager] Could not refresh session title:", err);
479
+ }
449
480
  }
450
481
  }
451
482
  /**
@@ -523,7 +554,12 @@ class PinnedMessageManager {
523
554
  logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
524
555
  }
525
556
  catch (err) {
526
- logger.error("[PinnedManager] Error fetching context limit:", err);
557
+ if (isExpectedOpencodeUnavailableError(err)) {
558
+ logger.warn("[PinnedManager] OpenCode server unavailable; using default context limit");
559
+ }
560
+ else {
561
+ logger.error("[PinnedManager] Error fetching context limit:", err);
562
+ }
527
563
  this.contextLimit = DEFAULT_CONTEXT_LIMIT;
528
564
  this.state.tokensLimit = this.contextLimit;
529
565
  }
@@ -2,6 +2,7 @@ import { config } from "../config.js";
2
2
  import { t } from "../i18n/index.js";
3
3
  import { opencodeClient } from "../opencode/client.js";
4
4
  import { logger } from "../utils/logger.js";
5
+ import { cleanupScheduledTaskSessionIgnores, registerScheduledTaskSessionIgnore, } from "./session-ignore.js";
5
6
  export const SCHEDULED_TASK_AGENT = "build";
6
7
  const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
7
8
  const EXECUTION_POLL_INTERVAL_MS = 2000;
@@ -298,6 +299,7 @@ export async function executeScheduledTask(task) {
298
299
  let sessionId = null;
299
300
  let deleteTemporarySession = true;
300
301
  try {
302
+ await cleanupScheduledTaskSessionIgnores();
301
303
  const { data: session, error: createError } = await opencodeClient.session.create({
302
304
  directory: task.projectWorktree,
303
305
  title: SCHEDULED_TASK_SESSION_TITLE,
@@ -306,6 +308,7 @@ export async function executeScheduledTask(task) {
306
308
  throw createError || new Error("Failed to create temporary scheduled task session");
307
309
  }
308
310
  sessionId = session.id;
311
+ await registerScheduledTaskSessionIgnore(session.id);
309
312
  const promptOptions = {
310
313
  sessionID: session.id,
311
314
  directory: session.directory,
@@ -7,6 +7,7 @@ import { sendBotText } from "../bot/utils/telegram-text.js";
7
7
  import { formatAssistantRunFooter } from "../bot/utils/assistant-run-footer.js";
8
8
  import { executeScheduledTask, SCHEDULED_TASK_AGENT } from "./executor.js";
9
9
  import { foregroundSessionState } from "./foreground-state.js";
10
+ import { cleanupScheduledTaskSessionIgnores } from "./session-ignore.js";
10
11
  import { computeNextRunAt, isTaskDue } from "./next-run.js";
11
12
  import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
12
13
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
@@ -96,6 +97,7 @@ export class ScheduledTaskRuntime {
96
97
  return;
97
98
  }
98
99
  this.initialized = true;
100
+ await cleanupScheduledTaskSessionIgnores();
99
101
  await this.recoverTasksOnStartup();
100
102
  await this.flushDeferredDeliveries();
101
103
  }
@@ -1,5 +1,6 @@
1
1
  import { opencodeClient } from "../opencode/client.js";
2
2
  import { logger } from "../utils/logger.js";
3
+ import { cleanupScheduledTaskSessionIgnores, registerScheduledTaskSessionIgnore, } from "./session-ignore.js";
3
4
  const SCHEDULE_PARSE_SESSION_TITLE = "Scheduled task schedule parser";
4
5
  function getLocalTimezone() {
5
6
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
@@ -133,6 +134,8 @@ export async function parseTaskSchedule(scheduleText, directory) {
133
134
  const timezone = getLocalTimezone();
134
135
  let sessionId = null;
135
136
  try {
137
+ logger.debug(`[ScheduledTaskScheduleParser] Parsing schedule: directory=${trimmedDirectory}, textLength=${trimmedScheduleText.length}`);
138
+ await cleanupScheduledTaskSessionIgnores();
136
139
  const { data: session, error: createError } = await opencodeClient.session.create({
137
140
  directory: trimmedDirectory,
138
141
  title: SCHEDULE_PARSE_SESSION_TITLE,
@@ -141,6 +144,8 @@ export async function parseTaskSchedule(scheduleText, directory) {
141
144
  throw createError || new Error("Failed to create temporary schedule parser session");
142
145
  }
143
146
  sessionId = session.id;
147
+ await registerScheduledTaskSessionIgnore(session.id);
148
+ logger.debug(`[ScheduledTaskScheduleParser] Created temporary session: sessionId=${session.id}`);
144
149
  const { data: response, error: promptError } = await opencodeClient.session.prompt({
145
150
  sessionID: session.id,
146
151
  directory: session.directory,
@@ -151,6 +156,7 @@ export async function parseTaskSchedule(scheduleText, directory) {
151
156
  throw promptError || new Error("Failed to parse schedule");
152
157
  }
153
158
  const responseText = collectResponseText(response.parts);
159
+ logger.debug(`[ScheduledTaskScheduleParser] Received parser response: sessionId=${session.id}, textLength=${responseText.length}`);
154
160
  if (!responseText) {
155
161
  throw new Error("Schedule parser returned an empty response");
156
162
  }
@@ -0,0 +1,53 @@
1
+ import { getScheduledTaskSessionIgnores, setScheduledTaskSessionIgnores, } from "../settings/manager.js";
2
+ const SCHEDULED_TASK_SESSION_IGNORE_TTL_MS = 24 * 60 * 60 * 1000;
3
+ let mutationQueue = Promise.resolve();
4
+ function isFreshIgnore(ignore, nowMs) {
5
+ const createdAtMs = Date.parse(ignore.createdAt);
6
+ return !Number.isNaN(createdAtMs) && nowMs - createdAtMs < SCHEDULED_TASK_SESSION_IGNORE_TTL_MS;
7
+ }
8
+ function pruneExpiredIgnores(ignores, nowMs) {
9
+ return ignores.filter((ignore) => isFreshIgnore(ignore, nowMs));
10
+ }
11
+ async function mutateIgnores(mutator) {
12
+ const runMutation = async () => {
13
+ const currentIgnores = getScheduledTaskSessionIgnores();
14
+ const { ignores, result } = mutator(currentIgnores);
15
+ await setScheduledTaskSessionIgnores(ignores);
16
+ return result;
17
+ };
18
+ const mutationPromise = mutationQueue.then(runMutation, runMutation);
19
+ mutationQueue = mutationPromise.catch(() => undefined);
20
+ return mutationPromise;
21
+ }
22
+ export function isScheduledTaskSessionIgnored(sessionId, now = new Date()) {
23
+ const nowMs = now.getTime();
24
+ return getScheduledTaskSessionIgnores().some((ignore) => ignore.sessionId === sessionId && isFreshIgnore(ignore, nowMs));
25
+ }
26
+ export async function registerScheduledTaskSessionIgnore(sessionId, createdAt = new Date()) {
27
+ await mutateIgnores((ignores) => {
28
+ const nowMs = createdAt.getTime();
29
+ const nextIgnores = pruneExpiredIgnores(ignores, nowMs).filter((ignore) => ignore.sessionId !== sessionId);
30
+ return {
31
+ ignores: [...nextIgnores, { sessionId, createdAt: createdAt.toISOString() }],
32
+ result: undefined,
33
+ };
34
+ });
35
+ }
36
+ export async function removeScheduledTaskSessionIgnore(sessionId) {
37
+ await mutateIgnores((ignores) => ({
38
+ ignores: ignores.filter((ignore) => ignore.sessionId !== sessionId),
39
+ result: undefined,
40
+ }));
41
+ }
42
+ export async function cleanupScheduledTaskSessionIgnores(now = new Date()) {
43
+ return mutateIgnores((ignores) => {
44
+ const nextIgnores = pruneExpiredIgnores(ignores, now.getTime());
45
+ return {
46
+ ignores: nextIgnores,
47
+ result: ignores.length - nextIgnores.length,
48
+ };
49
+ });
50
+ }
51
+ export function __resetScheduledTaskSessionIgnoreForTests() {
52
+ mutationQueue = Promise.resolve();
53
+ }
@@ -5,6 +5,9 @@ import { logger } from "../utils/logger.js";
5
5
  function cloneScheduledTasks(tasks) {
6
6
  return tasks?.map((task) => cloneScheduledTask(task));
7
7
  }
8
+ function cloneScheduledTaskSessionIgnores(ignores) {
9
+ return ignores?.map((ignore) => ({ ...ignore }));
10
+ }
8
11
  function getSettingsFilePath() {
9
12
  return getRuntimePaths().settingsFilePath;
10
13
  }
@@ -121,6 +124,13 @@ export function setScheduledTasks(tasks) {
121
124
  currentSettings.scheduledTasks = cloneScheduledTasks(tasks);
122
125
  return writeSettingsFile(currentSettings);
123
126
  }
127
+ export function getScheduledTaskSessionIgnores() {
128
+ return cloneScheduledTaskSessionIgnores(currentSettings.scheduledTaskSessionIgnores) ?? [];
129
+ }
130
+ export function setScheduledTaskSessionIgnores(ignores) {
131
+ currentSettings.scheduledTaskSessionIgnores = cloneScheduledTaskSessionIgnores(ignores);
132
+ return writeSettingsFile(currentSettings);
133
+ }
124
134
  export function __resetSettingsForTests() {
125
135
  currentSettings = {};
126
136
  settingsWriteQueue = Promise.resolve();
@@ -138,6 +148,8 @@ export async function loadSettings() {
138
148
  }
139
149
  currentSettings = loadedSettings;
140
150
  currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
151
+ currentSettings.scheduledTaskSessionIgnores =
152
+ cloneScheduledTaskSessionIgnores(loadedSettings.scheduledTaskSessionIgnores) ?? [];
141
153
  if (requiresRewrite) {
142
154
  void writeSettingsFile(currentSettings);
143
155
  }
@@ -0,0 +1,13 @@
1
+ export function isExpectedOpencodeUnavailableError(error) {
2
+ if (!error) {
3
+ return false;
4
+ }
5
+ const message = error instanceof Error ? error.message : String(error);
6
+ const normalized = message.toLowerCase();
7
+ return (normalized.includes("fetch failed") ||
8
+ normalized.includes("failed to fetch") ||
9
+ normalized.includes("econnrefused") ||
10
+ normalized.includes("econnreset") ||
11
+ normalized.includes("enotfound") ||
12
+ normalized.includes("connectex"));
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.19.2",
3
+ "version": "0.20.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",