@grinev/opencode-telegram-bot 0.19.3 → 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.
package/.env.example CHANGED
@@ -83,6 +83,11 @@ OPENCODE_MODEL_ID=big-pickle
83
83
  # Hide tool file edit documents sent as .txt attachments (default: false)
84
84
  # HIDE_TOOL_FILE_MESSAGES=false
85
85
 
86
+ # Track background sessions in the currently selected project/worktree (default: true)
87
+ # Sends short notifications when detached or non-current sessions in the current directory reply,
88
+ # ask questions, or request permissions. Other projects/worktrees are not tracked.
89
+ # TRACK_BACKGROUND_SESSIONS=true
90
+
86
91
  # Assistant message formatting mode (default: markdown)
87
92
  # markdown = convert assistant replies to Telegram MarkdownV2
88
93
  # raw = show assistant replies as plain text
package/README.md CHANGED
@@ -28,6 +28,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
28
28
  - **Remote coding** — send prompts to OpenCode from anywhere, receive complete results with code sent as files
29
29
  - **Session management** — create new sessions or continue existing ones, just like in the TUI
30
30
  - **Track live session** — follow a live OpenCode CLI session; see [Track Existing Session](#track-existing-session)
31
+ - **Background session notifications** — get short notifications when detached or non-current sessions in the current project/worktree reply, ask questions, or request permissions
31
32
  - **Live status** — pinned message with current project/worktree, model, context usage, and changed files list, updated in real time
32
33
  - **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
33
34
  - **Agent modes** — switch between Plan and Build modes on the fly
@@ -43,6 +44,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
43
44
  - **Git worktree switching** — browse and switch between existing git worktrees for the current repository with `/worktree`
44
45
  - **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
45
46
  - **Localization** — UI localization is supported for multiple languages (`BOT_LOCALE`)
47
+ - **Interactive file browser** — use `/ls` to browse files and directories inside the current project, open subdirectories, go back, and download files by tapping them
46
48
 
47
49
  Planned features currently in development are listed in [Current Task List](PRODUCT.md#current-task-list).
48
50
 
@@ -130,10 +132,12 @@ opencode-telegram config
130
132
  | `/status` | Server health, current project, session, and model info |
131
133
  | `/new` | Create a new session |
132
134
  | `/abort` | Abort the current task |
135
+ | `/detach` | Detach from the current session without stopping it |
133
136
  | `/sessions` | Browse and switch between recent sessions |
134
137
  | `/projects` | Switch between OpenCode projects |
135
138
  | `/worktree` | Switch between existing git worktrees |
136
139
  | `/open` | Add a project by browsing directories |
140
+ | `/ls` | List directory contents, then tap to open or download |
137
141
  | `/tts` | Toggle audio replies |
138
142
  | `/rename` | Rename the current session |
139
143
  | `/commands` | Browse and run custom commands |
@@ -213,6 +217,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
213
217
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
214
218
  | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
215
219
  | `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
220
+ | `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` |
216
221
  | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
217
222
  | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
218
223
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
@@ -1,5 +1,4 @@
1
1
  import { opencodeClient } from "../opencode/client.js";
2
- import { stopEventListening } from "../opencode/events.js";
3
2
  import { isOpencodeServerHealthy } from "../opencode/ready-refresh.js";
4
3
  import { summaryAggregator } from "../summary/aggregator.js";
5
4
  import { pinnedMessageManager } from "../pinned/manager.js";
@@ -180,7 +179,6 @@ export function detachAttachedSession(reason) {
180
179
  if (!attachManager.isAttached()) {
181
180
  return;
182
181
  }
183
- stopEventListening();
184
182
  summaryAggregator.clear();
185
183
  attachManager.clear(reason);
186
184
  void syncPinnedAttachState();
@@ -0,0 +1,137 @@
1
+ import { isScheduledTaskSessionIgnored } from "../scheduled-task/session-ignore.js";
2
+ import { logger } from "../utils/logger.js";
3
+ class BackgroundSessionTracker {
4
+ directory = null;
5
+ onNotification = null;
6
+ sessionTitles = new Map();
7
+ childSessionIds = new Set();
8
+ completedAssistantMessageIds = new Set();
9
+ pendingAssistantResponsesBySessionId = new Map();
10
+ questionRequestIds = new Set();
11
+ permissionRequestIds = new Set();
12
+ setDirectory(directory) {
13
+ if (this.directory === directory) {
14
+ return;
15
+ }
16
+ this.clear();
17
+ this.directory = directory;
18
+ }
19
+ setOnNotification(callback) {
20
+ this.onNotification = callback;
21
+ }
22
+ clear() {
23
+ this.directory = null;
24
+ this.sessionTitles.clear();
25
+ this.childSessionIds.clear();
26
+ this.completedAssistantMessageIds.clear();
27
+ this.pendingAssistantResponsesBySessionId.clear();
28
+ this.questionRequestIds.clear();
29
+ this.permissionRequestIds.clear();
30
+ }
31
+ processEvent(event, currentSessionId) {
32
+ switch (event.type) {
33
+ case "session.created":
34
+ case "session.updated":
35
+ this.handleSessionInfo(event.properties);
36
+ break;
37
+ case "message.updated":
38
+ this.handleMessageUpdated(event.properties, currentSessionId);
39
+ break;
40
+ case "session.idle":
41
+ this.handleSessionIdle(event.properties, currentSessionId);
42
+ break;
43
+ case "question.asked":
44
+ this.handleRequestEvent("question_asked", event.properties, currentSessionId);
45
+ break;
46
+ case "permission.asked":
47
+ this.handleRequestEvent("permission_asked", event.properties, currentSessionId);
48
+ break;
49
+ default:
50
+ break;
51
+ }
52
+ }
53
+ handleSessionInfo(properties) {
54
+ const info = properties.info;
55
+ if (!info?.id) {
56
+ return;
57
+ }
58
+ const title = info.title?.trim();
59
+ if (title) {
60
+ this.sessionTitles.set(info.id, title);
61
+ }
62
+ if (info.parentID) {
63
+ this.childSessionIds.add(info.id);
64
+ }
65
+ }
66
+ handleMessageUpdated(properties, currentSessionId) {
67
+ const info = properties.info;
68
+ const sessionId = info?.sessionID;
69
+ const messageId = info?.id;
70
+ if (!sessionId || !messageId || info?.role !== "assistant" || !info.time?.completed) {
71
+ return;
72
+ }
73
+ if (this.shouldIgnoreSession(sessionId, currentSessionId)) {
74
+ return;
75
+ }
76
+ if (this.completedAssistantMessageIds.has(messageId)) {
77
+ return;
78
+ }
79
+ this.completedAssistantMessageIds.add(messageId);
80
+ this.pendingAssistantResponsesBySessionId.set(sessionId, { messageId });
81
+ }
82
+ handleSessionIdle(properties, currentSessionId) {
83
+ const sessionId = properties.sessionID;
84
+ if (!sessionId || this.shouldIgnoreSession(sessionId, currentSessionId)) {
85
+ return;
86
+ }
87
+ const pendingResponse = this.pendingAssistantResponsesBySessionId.get(sessionId);
88
+ if (!pendingResponse) {
89
+ return;
90
+ }
91
+ this.pendingAssistantResponsesBySessionId.delete(sessionId);
92
+ this.emitNotification({
93
+ kind: "assistant_response",
94
+ sessionId,
95
+ sessionTitle: this.sessionTitles.get(sessionId),
96
+ messageId: pendingResponse.messageId,
97
+ });
98
+ }
99
+ handleRequestEvent(kind, properties, currentSessionId) {
100
+ const { id, sessionID: sessionId } = properties;
101
+ if (!id || !sessionId) {
102
+ return;
103
+ }
104
+ if (this.shouldIgnoreSession(sessionId, currentSessionId)) {
105
+ return;
106
+ }
107
+ const deliveredRequestIds = kind === "question_asked" ? this.questionRequestIds : this.permissionRequestIds;
108
+ if (deliveredRequestIds.has(id)) {
109
+ return;
110
+ }
111
+ deliveredRequestIds.add(id);
112
+ this.emitNotification({
113
+ kind,
114
+ sessionId,
115
+ sessionTitle: this.sessionTitles.get(sessionId),
116
+ requestId: id,
117
+ });
118
+ }
119
+ shouldIgnoreSession(sessionId, currentSessionId) {
120
+ return (sessionId === currentSessionId ||
121
+ this.childSessionIds.has(sessionId) ||
122
+ isScheduledTaskSessionIgnored(sessionId));
123
+ }
124
+ emitNotification(notification) {
125
+ if (!this.onNotification) {
126
+ return;
127
+ }
128
+ const callback = this.onNotification;
129
+ setImmediate(() => {
130
+ Promise.resolve(callback(notification)).catch((error) => {
131
+ logger.error("[BackgroundSessionTracker] Failed to deliver notification:", error);
132
+ });
133
+ });
134
+ }
135
+ }
136
+ export { BackgroundSessionTracker };
137
+ export const backgroundSessionTracker = new BackgroundSessionTracker();
@@ -7,6 +7,7 @@ const COMMAND_DEFINITIONS = [
7
7
  { command: "status", descriptionKey: "cmd.description.status" },
8
8
  { command: "new", descriptionKey: "cmd.description.new" },
9
9
  { command: "abort", descriptionKey: "cmd.description.stop" },
10
+ { command: "detach", descriptionKey: "cmd.description.detach" },
10
11
  { command: "sessions", descriptionKey: "cmd.description.sessions" },
11
12
  { command: "tts", descriptionKey: "cmd.description.tts" },
12
13
  { command: "projects", descriptionKey: "cmd.description.projects" },
@@ -20,6 +21,7 @@ const COMMAND_DEFINITIONS = [
20
21
  { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
21
22
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
22
23
  { command: "open", descriptionKey: "cmd.description.open" },
24
+ { command: "ls", descriptionKey: "cmd.description.ls" },
23
25
  { command: "help", descriptionKey: "cmd.description.help" },
24
26
  ];
25
27
  export function getLocalizedBotCommands() {
@@ -0,0 +1,54 @@
1
+ import { getCurrentProject } from "../../settings/manager.js";
2
+ import { clearSession, getCurrentSession } from "../../session/manager.js";
3
+ import { detachAttachedSession } from "../../attach/service.js";
4
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
5
+ import { pinnedMessageManager } from "../../pinned/manager.js";
6
+ import { keyboardManager } from "../../keyboard/manager.js";
7
+ import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
8
+ import { assistantRunState } from "../assistant-run-state.js";
9
+ import { clearPromptResponseMode } from "../handlers/prompt.js";
10
+ import { logger } from "../../utils/logger.js";
11
+ import { t } from "../../i18n/index.js";
12
+ export async function detachCommand(ctx) {
13
+ try {
14
+ const currentProject = getCurrentProject();
15
+ if (!currentProject) {
16
+ await ctx.reply(t("detach.project_not_selected"));
17
+ return;
18
+ }
19
+ const currentSession = getCurrentSession();
20
+ if (!currentSession) {
21
+ await ctx.reply(t("detach.no_active_session"));
22
+ return;
23
+ }
24
+ detachAttachedSession("detach_command");
25
+ clearPromptResponseMode(currentSession.id);
26
+ foregroundSessionState.markIdle(currentSession.id);
27
+ assistantRunState.clearRun(currentSession.id, "detach_command");
28
+ clearAllInteractionState("detach_command");
29
+ clearSession();
30
+ if (pinnedMessageManager.isInitialized()) {
31
+ try {
32
+ await pinnedMessageManager.clear();
33
+ }
34
+ catch (error) {
35
+ logger.error("[Detach] Failed to clear pinned message:", error);
36
+ }
37
+ }
38
+ if (ctx.chat) {
39
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
40
+ }
41
+ await pinnedMessageManager.refreshContextLimit();
42
+ const contextLimit = pinnedMessageManager.getContextLimit();
43
+ keyboardManager.updateContext(0, contextLimit);
44
+ const keyboard = keyboardManager.getKeyboard();
45
+ logger.info(`[Detach] Detached from session: id=${currentSession.id}, title="${currentSession.title}", project=${currentProject.worktree}`);
46
+ await ctx.reply(t("detach.success", { title: currentSession.title }), {
47
+ ...(keyboard ? { reply_markup: keyboard } : {}),
48
+ });
49
+ }
50
+ catch (error) {
51
+ logger.error("[Detach] Failed to detach from current session:", error);
52
+ await ctx.reply(t("detach.error"));
53
+ }
54
+ }