@grinev/opencode-telegram-bot 0.19.3 → 0.20.1
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 +5 -0
- package/README.md +5 -0
- package/dist/attach/service.js +0 -2
- package/dist/background-session/tracker.js +137 -0
- package/dist/bot/commands/commands.js +1 -1
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/detach.js +54 -0
- package/dist/bot/commands/ls.js +445 -0
- package/dist/bot/commands/open.js +9 -4
- package/dist/bot/commands/projects.js +6 -2
- package/dist/bot/commands/sessions.js +233 -82
- package/dist/bot/commands/worktree.js +7 -2
- package/dist/bot/handlers/inline-menu.js +1 -0
- package/dist/bot/handlers/prompt.js +1 -1
- package/dist/bot/index.js +74 -8
- package/dist/bot/utils/busy-reconciliation.js +113 -0
- package/dist/bot/utils/send-downloaded-file.js +57 -0
- package/dist/bot/utils/switch-project.js +9 -14
- package/dist/config.js +1 -0
- package/dist/i18n/de.js +26 -0
- package/dist/i18n/en.js +26 -0
- package/dist/i18n/es.js +26 -0
- package/dist/i18n/fr.js +26 -0
- package/dist/i18n/ru.js +26 -0
- package/dist/i18n/zh.js +26 -0
- package/dist/interaction/busy.js +1 -1
- package/dist/interaction/manager.js +1 -1
- package/dist/scheduled-task/executor.js +3 -0
- package/dist/scheduled-task/foreground-state.js +22 -12
- package/dist/scheduled-task/runtime.js +2 -0
- package/dist/scheduled-task/schedule-parser.js +6 -0
- package/dist/scheduled-task/session-ignore.js +53 -0
- package/dist/settings/manager.js +12 -0
- package/dist/summary/aggregator.js +4 -0
- package/package.json +1 -1
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` |
|
package/dist/attach/service.js
CHANGED
|
@@ -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();
|
|
@@ -292,7 +292,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
292
292
|
const model = storedModel.providerID && storedModel.modelID
|
|
293
293
|
? `${storedModel.providerID}/${storedModel.modelID}`
|
|
294
294
|
: undefined;
|
|
295
|
-
foregroundSessionState.markBusy(session.id);
|
|
295
|
+
foregroundSessionState.markBusy(session.id, session.directory);
|
|
296
296
|
await markAttachedSessionBusy(session.id);
|
|
297
297
|
assistantRunState.startRun(session.id, {
|
|
298
298
|
startedAt: Date.now(),
|
|
@@ -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
|
+
}
|