@grinev/opencode-telegram-bot 0.15.0 → 0.16.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 +13 -0
- package/README.md +6 -0
- package/dist/app/start-bot-app.js +6 -1
- package/dist/bot/assistant-run-state.js +60 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +10 -0
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/open.js +314 -0
- package/dist/bot/commands/projects.js +5 -38
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/handlers/inline-menu.js +9 -1
- package/dist/bot/handlers/prompt.js +11 -0
- package/dist/bot/index.js +162 -98
- package/dist/bot/streaming/response-streamer.js +26 -17
- package/dist/bot/utils/assistant-rendering.js +117 -0
- package/dist/bot/utils/assistant-run-footer.js +9 -0
- package/dist/bot/utils/browser-roots.js +140 -0
- package/dist/bot/utils/file-tree.js +92 -0
- package/dist/bot/utils/finalize-assistant-response.js +18 -24
- package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
- package/dist/bot/utils/switch-project.js +48 -0
- package/dist/bot/utils/telegram-text.js +95 -1
- package/dist/cli.js +4 -0
- package/dist/config.js +3 -0
- package/dist/i18n/de.js +15 -0
- package/dist/i18n/en.js +15 -0
- package/dist/i18n/es.js +15 -0
- package/dist/i18n/fr.js +15 -0
- package/dist/i18n/ru.js +15 -0
- package/dist/i18n/zh.js +15 -0
- package/dist/index.js +2 -0
- package/dist/project/manager.js +2 -1
- package/dist/summary/aggregator.js +17 -1
- package/dist/summary/formatter.js +2 -88
- package/dist/telegram/render/block-fallback.js +28 -0
- package/dist/telegram/render/block-parser.js +295 -0
- package/dist/telegram/render/block-renderer.js +457 -0
- package/dist/telegram/render/chunker.js +281 -0
- package/dist/telegram/render/inline-renderer.js +128 -0
- package/dist/telegram/render/markdown-normalizer.js +94 -0
- package/dist/telegram/render/pipeline.js +9 -0
- package/dist/telegram/render/types.js +1 -0
- package/dist/telegram/render/validator.js +160 -0
- package/dist/utils/logger.js +200 -73
- package/package.json +6 -2
package/.env.example
CHANGED
|
@@ -33,6 +33,11 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
33
33
|
# Use "debug" to see detailed diagnostic logs including all bot events
|
|
34
34
|
# LOG_LEVEL=info
|
|
35
35
|
|
|
36
|
+
# Number of log files to keep (default: 10)
|
|
37
|
+
# sources mode: keeps the latest N launch logs
|
|
38
|
+
# installed mode: keeps the latest N daily logs
|
|
39
|
+
# LOG_RETENTION=10
|
|
40
|
+
|
|
36
41
|
# Bot Configuration (optional)
|
|
37
42
|
# Maximum number of sessions shown in /sessions (default: 10)
|
|
38
43
|
# SESSIONS_LIST_LIMIT=10
|
|
@@ -69,6 +74,14 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
69
74
|
# raw = show assistant replies as plain text
|
|
70
75
|
# MESSAGE_FORMAT_MODE=markdown
|
|
71
76
|
|
|
77
|
+
# Directory Browser Roots (optional)
|
|
78
|
+
# Comma-separated list of paths that /open is allowed to browse.
|
|
79
|
+
# Supports ~ for home directory. Defaults to ~ when not set.
|
|
80
|
+
# Examples:
|
|
81
|
+
# OPEN_BROWSER_ROOTS=~/projects
|
|
82
|
+
# OPEN_BROWSER_ROOTS=~/projects,~/work,/opt/repos
|
|
83
|
+
# OPEN_BROWSER_ROOTS=
|
|
84
|
+
|
|
72
85
|
# Code File Settings (optional)
|
|
73
86
|
# Maximum file size in KB to send as document (default: 100)
|
|
74
87
|
# CODE_FILE_MAX_SIZE_KB=100
|
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://github.com/grinev/opencode-telegram-bot/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
|
+
[](https://x.com/grin_rus)
|
|
7
8
|
|
|
8
9
|
OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
|
|
9
10
|
|
|
@@ -109,6 +110,7 @@ opencode-telegram config
|
|
|
109
110
|
| `/abort` | Abort the current task |
|
|
110
111
|
| `/sessions` | Browse and switch between recent sessions |
|
|
111
112
|
| `/projects` | Switch between OpenCode projects |
|
|
113
|
+
| `/open` | Add a project by browsing directories |
|
|
112
114
|
| `/tts` | Toggle audio replies |
|
|
113
115
|
| `/rename` | Rename the current session |
|
|
114
116
|
| `/commands` | Browse and run custom commands |
|
|
@@ -161,6 +163,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
161
163
|
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
162
164
|
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
163
165
|
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
166
|
+
| `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
|
|
164
167
|
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
|
|
165
168
|
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
|
|
166
169
|
| `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
|
|
@@ -179,9 +182,12 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
179
182
|
| `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
|
|
180
183
|
| `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
|
|
181
184
|
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
185
|
+
| `LOG_RETENTION` | Number of log files to keep: launch files in `sources`, daily files in `installed` | No | `10` |
|
|
182
186
|
|
|
183
187
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
184
188
|
|
|
189
|
+
Logs are written to `./logs` when running from sources and to the runtime config directory `logs/` folder in `installed` mode. Log rotation depends on runtime mode: `sources` creates one file per bot launch, while `installed` appends to one file per day. Old log files are removed according to `LOG_RETENTION`.
|
|
190
|
+
|
|
185
191
|
### Voice and Audio Transcription (Optional)
|
|
186
192
|
|
|
187
193
|
If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
|
|
@@ -8,7 +8,7 @@ import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
|
|
|
8
8
|
import { reconcileStoredModelSelection } from "../model/manager.js";
|
|
9
9
|
import { getRuntimeMode } from "../runtime/mode.js";
|
|
10
10
|
import { getRuntimePaths } from "../runtime/paths.js";
|
|
11
|
-
import { logger } from "../utils/logger.js";
|
|
11
|
+
import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
|
|
12
12
|
async function getBotVersion() {
|
|
13
13
|
try {
|
|
14
14
|
const packageJsonPath = new URL("../../package.json", import.meta.url);
|
|
@@ -22,11 +22,16 @@ async function getBotVersion() {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
export async function startBotApp() {
|
|
25
|
+
await initializeLogger();
|
|
25
26
|
const mode = getRuntimeMode();
|
|
26
27
|
const runtimePaths = getRuntimePaths();
|
|
27
28
|
const version = await getBotVersion();
|
|
29
|
+
const logFilePath = getLogFilePath();
|
|
28
30
|
logger.info(`Starting OpenCode Telegram Bot v${version}...`);
|
|
29
31
|
logger.info(`Config loaded from ${runtimePaths.envFilePath}`);
|
|
32
|
+
if (logFilePath) {
|
|
33
|
+
logger.info(`Logs are written to ${logFilePath}`);
|
|
34
|
+
}
|
|
30
35
|
logger.info(`Allowed User ID: ${config.telegram.allowedUserId}`);
|
|
31
36
|
logger.debug(`[Runtime] Application start mode: ${mode}`);
|
|
32
37
|
await loadSettings();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
class AssistantRunState {
|
|
3
|
+
runs = new Map();
|
|
4
|
+
startRun(sessionId, info) {
|
|
5
|
+
if (!sessionId) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
this.runs.set(sessionId, {
|
|
9
|
+
sessionId,
|
|
10
|
+
startedAt: info.startedAt,
|
|
11
|
+
configuredAgent: info.configuredAgent,
|
|
12
|
+
configuredProviderID: info.configuredProviderID,
|
|
13
|
+
configuredModelID: info.configuredModelID,
|
|
14
|
+
hasCompletedResponse: false,
|
|
15
|
+
});
|
|
16
|
+
logger.debug(`[AssistantRunState] Started run: session=${sessionId}, agent=${info.configuredAgent || "unknown"}, model=${info.configuredProviderID || "unknown"}/${info.configuredModelID || "unknown"}`);
|
|
17
|
+
}
|
|
18
|
+
markResponseCompleted(sessionId, info) {
|
|
19
|
+
const run = this.runs.get(sessionId);
|
|
20
|
+
if (!run) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
run.hasCompletedResponse = true;
|
|
24
|
+
if (info?.agent) {
|
|
25
|
+
run.actualAgent = info.agent;
|
|
26
|
+
}
|
|
27
|
+
if (info?.providerID) {
|
|
28
|
+
run.actualProviderID = info.providerID;
|
|
29
|
+
}
|
|
30
|
+
if (info?.modelID) {
|
|
31
|
+
run.actualModelID = info.modelID;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finishRun(sessionId, reason) {
|
|
35
|
+
const run = this.runs.get(sessionId) ?? null;
|
|
36
|
+
if (!run) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
this.runs.delete(sessionId);
|
|
40
|
+
logger.debug(`[AssistantRunState] Finished run: session=${sessionId}, reason=${reason}`);
|
|
41
|
+
return { ...run };
|
|
42
|
+
}
|
|
43
|
+
clearRun(sessionId, reason) {
|
|
44
|
+
if (!this.runs.delete(sessionId)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
logger.debug(`[AssistantRunState] Cleared run: session=${sessionId}, reason=${reason}`);
|
|
48
|
+
}
|
|
49
|
+
clearAll(reason) {
|
|
50
|
+
if (this.runs.size === 0) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
logger.debug(`[AssistantRunState] Cleared all runs: count=${this.runs.size}, reason=${reason}`);
|
|
54
|
+
this.runs.clear();
|
|
55
|
+
}
|
|
56
|
+
__resetForTests() {
|
|
57
|
+
this.runs.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const assistantRunState = new AssistantRunState();
|
|
@@ -6,6 +6,7 @@ import { summaryAggregator } from "../../summary/aggregator.js";
|
|
|
6
6
|
import { logger } from "../../utils/logger.js";
|
|
7
7
|
import { t } from "../../i18n/index.js";
|
|
8
8
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
9
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
9
10
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
11
|
function abortLocalStreaming() {
|
|
11
12
|
stopEventListening();
|
|
@@ -86,6 +87,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
86
87
|
const finalStatus = await pollSessionStatus(currentSession.id, currentSession.directory, 5000);
|
|
87
88
|
if (finalStatus === "idle" || finalStatus === "not-found") {
|
|
88
89
|
foregroundSessionState.markIdle(currentSession.id);
|
|
90
|
+
assistantRunState.clearRun(currentSession.id, "abort_confirmed");
|
|
89
91
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
90
92
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.success"));
|
|
91
93
|
}
|
|
@@ -12,6 +12,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
12
12
|
import { t } from "../../i18n/index.js";
|
|
13
13
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
14
14
|
import { config } from "../../config.js";
|
|
15
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
15
16
|
const COMMANDS_CALLBACK_PREFIX = "commands:";
|
|
16
17
|
const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
|
|
17
18
|
const COMMANDS_CALLBACK_PAGE_PREFIX = `${COMMANDS_CALLBACK_PREFIX}page:`;
|
|
@@ -236,6 +237,7 @@ async function ensureSessionForProject(ctx, projectDirectory) {
|
|
|
236
237
|
clearSession();
|
|
237
238
|
summaryAggregator.clear();
|
|
238
239
|
foregroundSessionState.clearAll("session_mismatch_reset");
|
|
240
|
+
assistantRunState.clearAll("session_mismatch_reset");
|
|
239
241
|
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
240
242
|
currentSession = null;
|
|
241
243
|
}
|
|
@@ -285,6 +287,12 @@ async function executeCommand(ctx, deps, params) {
|
|
|
285
287
|
? `${storedModel.providerID}/${storedModel.modelID}`
|
|
286
288
|
: undefined;
|
|
287
289
|
foregroundSessionState.markBusy(session.id);
|
|
290
|
+
assistantRunState.startRun(session.id, {
|
|
291
|
+
startedAt: Date.now(),
|
|
292
|
+
configuredAgent: currentAgent,
|
|
293
|
+
configuredProviderID: storedModel.providerID,
|
|
294
|
+
configuredModelID: storedModel.modelID,
|
|
295
|
+
});
|
|
288
296
|
safeBackgroundTask({
|
|
289
297
|
taskName: "session.command",
|
|
290
298
|
task: () => opencodeClient.session.command({
|
|
@@ -299,6 +307,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
299
307
|
onSuccess: ({ error }) => {
|
|
300
308
|
if (error) {
|
|
301
309
|
foregroundSessionState.markIdle(session.id);
|
|
310
|
+
assistantRunState.clearRun(session.id, "session_command_api_error");
|
|
302
311
|
logger.error("[Commands] OpenCode API returned an error for session.command", {
|
|
303
312
|
sessionId: session.id,
|
|
304
313
|
command: params.commandName,
|
|
@@ -312,6 +321,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
312
321
|
},
|
|
313
322
|
onError: (error) => {
|
|
314
323
|
foregroundSessionState.markIdle(session.id);
|
|
324
|
+
assistantRunState.clearRun(session.id, "session_command_background_error");
|
|
315
325
|
logger.error("[Commands] session.command background task failed", {
|
|
316
326
|
sessionId: session.id,
|
|
317
327
|
command: params.commandName,
|
|
@@ -16,6 +16,7 @@ const COMMAND_DEFINITIONS = [
|
|
|
16
16
|
{ command: "commands", descriptionKey: "cmd.description.commands" },
|
|
17
17
|
{ command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
|
|
18
18
|
{ command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
|
|
19
|
+
{ command: "open", descriptionKey: "cmd.description.open" },
|
|
19
20
|
{ command: "help", descriptionKey: "cmd.description.help" },
|
|
20
21
|
];
|
|
21
22
|
export function getLocalizedBotCommands() {
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../handlers/inline-menu.js";
|
|
4
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
5
|
+
import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
|
|
6
|
+
import { pathToDisplayPath, scanDirectory, buildEntryLabel, buildTreeHeader, isScanError, MAX_ENTRIES_PER_PAGE, } from "../utils/file-tree.js";
|
|
7
|
+
import { getBrowserRoots, isWithinAllowedRoot, isAllowedRoot } from "../utils/browser-roots.js";
|
|
8
|
+
import { upsertSessionDirectory } from "../../session/cache-manager.js";
|
|
9
|
+
import { getProjectByWorktree } from "../../project/manager.js";
|
|
10
|
+
import { switchToProject } from "../utils/switch-project.js";
|
|
11
|
+
import { logger } from "../../utils/logger.js";
|
|
12
|
+
import { t } from "../../i18n/index.js";
|
|
13
|
+
const CALLBACK_PREFIX = "open:";
|
|
14
|
+
const CALLBACK_NAV_PREFIX = "open:nav:";
|
|
15
|
+
const CALLBACK_SELECT_PREFIX = "open:sel:";
|
|
16
|
+
const CALLBACK_PAGE_PREFIX = "open:pg:";
|
|
17
|
+
const CALLBACK_ROOTS = "open:roots";
|
|
18
|
+
const MAX_BUTTON_LABEL_LENGTH = 64;
|
|
19
|
+
/**
|
|
20
|
+
* Separator used inside pagination callback data between the encoded path
|
|
21
|
+
* reference and the page number. We avoid `:` because it appears in Windows
|
|
22
|
+
* drive letters (e.g. `C:\`) and is already used as a prefix delimiter.
|
|
23
|
+
*/
|
|
24
|
+
const PAGE_SEPARATOR = "|";
|
|
25
|
+
function truncateLabel(label, maxLen = MAX_BUTTON_LABEL_LENGTH) {
|
|
26
|
+
if (label.length <= maxLen) {
|
|
27
|
+
return label;
|
|
28
|
+
}
|
|
29
|
+
return label.slice(0, Math.max(0, maxLen - 3)) + "...";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Encode a path into callback data. Telegram limits callback_data to 64 bytes.
|
|
33
|
+
* Long absolute paths can exceed this, so we encode them as a compact index
|
|
34
|
+
* when necessary. The index is stored in a module-level map that lives for the
|
|
35
|
+
* duration of the current inline menu interaction.
|
|
36
|
+
*/
|
|
37
|
+
const pathIndex = new Map();
|
|
38
|
+
let pathCounter = 0;
|
|
39
|
+
/** Clear the path index. Exported so it can be called on menu cancel/cleanup. */
|
|
40
|
+
export function clearOpenPathIndex() {
|
|
41
|
+
pathIndex.clear();
|
|
42
|
+
pathCounter = 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* @param prefix Callback-data prefix that precedes the path.
|
|
46
|
+
* @param fullPath Absolute path to encode.
|
|
47
|
+
* @param reserveBytes Extra bytes to reserve for a suffix that will be
|
|
48
|
+
* appended *after* the returned value (e.g. the page separator + digits in
|
|
49
|
+
* pagination callbacks). The total callback_data must stay ≤ 64 bytes.
|
|
50
|
+
*/
|
|
51
|
+
function encodePathForCallback(prefix, fullPath, reserveBytes = 0) {
|
|
52
|
+
const naive = `${prefix}${fullPath}`;
|
|
53
|
+
if (Buffer.byteLength(naive, "utf-8") + reserveBytes <= 64) {
|
|
54
|
+
return naive;
|
|
55
|
+
}
|
|
56
|
+
// Use a short numeric key instead
|
|
57
|
+
const key = `#${pathCounter++}`;
|
|
58
|
+
pathIndex.set(key, fullPath);
|
|
59
|
+
return `${prefix}${key}`;
|
|
60
|
+
}
|
|
61
|
+
function decodePathFromCallback(prefix, data) {
|
|
62
|
+
if (!data.startsWith(prefix)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const raw = data.slice(prefix.length);
|
|
66
|
+
if (raw.startsWith("#")) {
|
|
67
|
+
return pathIndex.get(raw) ?? null;
|
|
68
|
+
}
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Encode a pagination callback. The path part goes through the same 64-byte
|
|
73
|
+
* safe encoding used by nav/select callbacks, followed by a separator and
|
|
74
|
+
* the page number.
|
|
75
|
+
*
|
|
76
|
+
* We reserve bytes for the page suffix so the 64-byte check inside
|
|
77
|
+
* `encodePathForCallback` accounts for the complete final callback length.
|
|
78
|
+
*/
|
|
79
|
+
function encodePaginationCallback(currentPath, page) {
|
|
80
|
+
const pageSuffix = `${PAGE_SEPARATOR}${page}`;
|
|
81
|
+
const reserveBytes = Buffer.byteLength(pageSuffix, "utf-8");
|
|
82
|
+
const pathRef = encodePathForCallback(CALLBACK_PAGE_PREFIX, currentPath, reserveBytes);
|
|
83
|
+
return `${pathRef}${pageSuffix}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Decode a pagination callback into { path, page } or null on failure.
|
|
87
|
+
*/
|
|
88
|
+
function decodePaginationCallback(data) {
|
|
89
|
+
if (!data.startsWith(CALLBACK_PAGE_PREFIX)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const payload = data.slice(CALLBACK_PAGE_PREFIX.length);
|
|
93
|
+
const sepIndex = payload.lastIndexOf(PAGE_SEPARATOR);
|
|
94
|
+
if (sepIndex < 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const pathRef = payload.slice(0, sepIndex);
|
|
98
|
+
const pageNum = Number.parseInt(payload.slice(sepIndex + 1), 10);
|
|
99
|
+
if (Number.isNaN(pageNum)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Resolve indexed path references
|
|
103
|
+
const resolvedPath = pathRef.startsWith("#") ? (pathIndex.get(pathRef) ?? null) : pathRef;
|
|
104
|
+
if (resolvedPath === null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return { path: resolvedPath, page: pageNum };
|
|
108
|
+
}
|
|
109
|
+
function buildBrowseKeyboard(entries, currentPath, hasParent, page, totalCount) {
|
|
110
|
+
const keyboard = new InlineKeyboard();
|
|
111
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
|
|
112
|
+
// Directory entries
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const label = truncateLabel(buildEntryLabel(entry));
|
|
115
|
+
keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, entry.fullPath)).row();
|
|
116
|
+
}
|
|
117
|
+
// Navigation: Up + Back to roots
|
|
118
|
+
// Suppress "Up" when at an allowed root (don't let user navigate above it)
|
|
119
|
+
const atRoot = isAllowedRoot(currentPath);
|
|
120
|
+
const showUp = hasParent && !atRoot;
|
|
121
|
+
const roots = getBrowserRoots();
|
|
122
|
+
const showRoots = roots.length > 1;
|
|
123
|
+
if (showUp || showRoots) {
|
|
124
|
+
if (showUp) {
|
|
125
|
+
const parentPath = path.dirname(currentPath);
|
|
126
|
+
keyboard.text(t("open.back"), encodePathForCallback(CALLBACK_NAV_PREFIX, parentPath));
|
|
127
|
+
}
|
|
128
|
+
if (showRoots) {
|
|
129
|
+
keyboard.text(t("open.roots"), CALLBACK_ROOTS);
|
|
130
|
+
}
|
|
131
|
+
keyboard.row();
|
|
132
|
+
}
|
|
133
|
+
// Pagination
|
|
134
|
+
if (totalPages > 1) {
|
|
135
|
+
if (page > 0) {
|
|
136
|
+
keyboard.text(t("open.prev_page"), encodePaginationCallback(currentPath, page - 1));
|
|
137
|
+
}
|
|
138
|
+
if (page < totalPages - 1) {
|
|
139
|
+
keyboard.text(t("open.next_page"), encodePaginationCallback(currentPath, page + 1));
|
|
140
|
+
}
|
|
141
|
+
keyboard.row();
|
|
142
|
+
}
|
|
143
|
+
// Select current folder
|
|
144
|
+
keyboard
|
|
145
|
+
.text(t("open.select_current"), encodePathForCallback(CALLBACK_SELECT_PREFIX, currentPath))
|
|
146
|
+
.row();
|
|
147
|
+
// Cancel
|
|
148
|
+
appendInlineMenuCancelButton(keyboard, "open");
|
|
149
|
+
return keyboard;
|
|
150
|
+
}
|
|
151
|
+
async function renderBrowseView(dirPath, page = 0) {
|
|
152
|
+
const result = await scanDirectory(dirPath, page);
|
|
153
|
+
if (isScanError(result)) {
|
|
154
|
+
return { error: result.error };
|
|
155
|
+
}
|
|
156
|
+
const { entries, totalCount, page: clampedPage, currentPath, displayPath, hasParent } = result;
|
|
157
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
|
|
158
|
+
const header = buildTreeHeader(displayPath, totalCount, clampedPage, totalPages);
|
|
159
|
+
const keyboard = buildBrowseKeyboard(entries, currentPath, hasParent, clampedPage, totalCount);
|
|
160
|
+
return { text: header, keyboard };
|
|
161
|
+
}
|
|
162
|
+
function buildRootsKeyboard() {
|
|
163
|
+
const keyboard = new InlineKeyboard();
|
|
164
|
+
const roots = getBrowserRoots();
|
|
165
|
+
for (const root of roots) {
|
|
166
|
+
const label = truncateLabel(`📂 ${pathToDisplayPath(root)}`);
|
|
167
|
+
keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, root)).row();
|
|
168
|
+
}
|
|
169
|
+
appendInlineMenuCancelButton(keyboard, "open");
|
|
170
|
+
return keyboard;
|
|
171
|
+
}
|
|
172
|
+
export async function openCommand(ctx) {
|
|
173
|
+
try {
|
|
174
|
+
if (isForegroundBusy()) {
|
|
175
|
+
await replyBusyBlocked(ctx);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Reset path index for new interaction
|
|
179
|
+
clearOpenPathIndex();
|
|
180
|
+
const roots = getBrowserRoots();
|
|
181
|
+
let text;
|
|
182
|
+
let keyboard;
|
|
183
|
+
if (roots.length === 1) {
|
|
184
|
+
// Single root — navigate directly into it
|
|
185
|
+
const view = await renderBrowseView(roots[0]);
|
|
186
|
+
if ("error" in view) {
|
|
187
|
+
await ctx.reply(t("open.scan_error", { error: view.error }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
text = view.text;
|
|
191
|
+
keyboard = view.keyboard;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Multiple roots — show root selection
|
|
195
|
+
text = t("open.select_root");
|
|
196
|
+
keyboard = buildRootsKeyboard();
|
|
197
|
+
}
|
|
198
|
+
const message = await ctx.reply(text, { reply_markup: keyboard });
|
|
199
|
+
interactionManager.start({
|
|
200
|
+
kind: "inline",
|
|
201
|
+
expectedInput: "callback",
|
|
202
|
+
metadata: {
|
|
203
|
+
menuKind: "open",
|
|
204
|
+
messageId: message.message_id,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
logger.error("[Bot] Error opening directory browser:", error);
|
|
210
|
+
await ctx.reply(t("open.open_error"));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export async function handleOpenCallback(ctx) {
|
|
214
|
+
const data = ctx.callbackQuery?.data;
|
|
215
|
+
if (!data || !data.startsWith(CALLBACK_PREFIX)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (isForegroundBusy()) {
|
|
219
|
+
await replyBusyBlocked(ctx);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "open");
|
|
223
|
+
if (!isActiveMenu) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
// Back to root selection (multi-root mode)
|
|
228
|
+
if (data === CALLBACK_ROOTS) {
|
|
229
|
+
await showRoots(ctx);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
// Navigate into a directory (including "up")
|
|
233
|
+
const navPath = decodePathFromCallback(CALLBACK_NAV_PREFIX, data);
|
|
234
|
+
if (navPath !== null) {
|
|
235
|
+
if (!isWithinAllowedRoot(navPath)) {
|
|
236
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
await navigateTo(ctx, navPath);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
// Pagination
|
|
243
|
+
const pageInfo = decodePaginationCallback(data);
|
|
244
|
+
if (pageInfo !== null) {
|
|
245
|
+
if (!isWithinAllowedRoot(pageInfo.path)) {
|
|
246
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
await navigateTo(ctx, pageInfo.path, pageInfo.page);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
// Select directory as project
|
|
253
|
+
const selectPath = decodePathFromCallback(CALLBACK_SELECT_PREFIX, data);
|
|
254
|
+
if (selectPath !== null) {
|
|
255
|
+
if (!isWithinAllowedRoot(selectPath)) {
|
|
256
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
await selectDirectory(ctx, selectPath);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
logger.error("[Bot] Error handling open callback:", error);
|
|
266
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function showRoots(ctx) {
|
|
271
|
+
const text = t("open.select_root");
|
|
272
|
+
const keyboard = buildRootsKeyboard();
|
|
273
|
+
await ctx.answerCallbackQuery();
|
|
274
|
+
await ctx.editMessageText(text, { reply_markup: keyboard });
|
|
275
|
+
}
|
|
276
|
+
async function navigateTo(ctx, dirPath, page = 0) {
|
|
277
|
+
const view = await renderBrowseView(dirPath, page);
|
|
278
|
+
if ("error" in view) {
|
|
279
|
+
await ctx.answerCallbackQuery({ text: view.error });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
await ctx.answerCallbackQuery();
|
|
283
|
+
await ctx.editMessageText(view.text, {
|
|
284
|
+
reply_markup: view.keyboard,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async function selectDirectory(ctx, directory) {
|
|
288
|
+
const displayPath = pathToDisplayPath(directory);
|
|
289
|
+
try {
|
|
290
|
+
logger.info(`[Bot] Adding project directory: ${directory}`);
|
|
291
|
+
// Register directory in the session cache first — getProjectByWorktree
|
|
292
|
+
// needs the entry to exist so it can resolve the project. If the
|
|
293
|
+
// subsequent switch fails, the entry stays in the cache, which is
|
|
294
|
+
// acceptable: it's a real directory the user explicitly selected and
|
|
295
|
+
// will simply appear in /projects for retry.
|
|
296
|
+
await upsertSessionDirectory(directory, Date.now());
|
|
297
|
+
const projectInfo = await getProjectByWorktree(directory);
|
|
298
|
+
const replyKeyboard = await switchToProject(ctx, { ...projectInfo, name: displayPath }, "open_project_selected");
|
|
299
|
+
await ctx.answerCallbackQuery();
|
|
300
|
+
await ctx.reply(t("open.selected", { project: displayPath }), {
|
|
301
|
+
reply_markup: replyKeyboard,
|
|
302
|
+
});
|
|
303
|
+
// Clean up the inline menu message
|
|
304
|
+
await ctx.deleteMessage();
|
|
305
|
+
// Clear path index after selection
|
|
306
|
+
clearOpenPathIndex();
|
|
307
|
+
logger.info(`[Bot] Project added and selected: ${displayPath}`);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
logger.error("[Bot] Error selecting directory:", error);
|
|
311
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
312
|
+
await ctx.reply(t("open.select_error"));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
|
-
import {
|
|
2
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
3
3
|
import { getProjects } from "../../project/manager.js";
|
|
4
4
|
import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
|
|
5
|
-
import { clearSession } from "../../session/manager.js";
|
|
6
|
-
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
7
|
-
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
8
|
-
import { keyboardManager } from "../../keyboard/manager.js";
|
|
9
|
-
import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
|
|
10
|
-
import { getStoredModel } from "../../model/manager.js";
|
|
11
|
-
import { formatVariantForButton } from "../../variant/manager.js";
|
|
12
|
-
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
13
|
-
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
14
5
|
import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
|
|
6
|
+
import { switchToProject } from "../utils/switch-project.js";
|
|
7
|
+
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
15
8
|
import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
|
|
16
9
|
import { logger } from "../../utils/logger.js";
|
|
17
10
|
import { t } from "../../i18n/index.js";
|
|
@@ -179,35 +172,9 @@ export async function handleProjectSelect(ctx) {
|
|
|
179
172
|
if (!selectedProject) {
|
|
180
173
|
throw new Error(`Project with id ${projectId} not found`);
|
|
181
174
|
}
|
|
182
|
-
logger.info(`[Bot] Project selected: ${selectedProject.name || selectedProject.worktree} (id: ${projectId})`);
|
|
183
|
-
setCurrentProject(selectedProject);
|
|
184
|
-
clearSession();
|
|
185
|
-
summaryAggregator.clear();
|
|
186
|
-
clearAllInteractionState("project_switched");
|
|
187
|
-
// Clear pinned message when switching projects
|
|
188
|
-
try {
|
|
189
|
-
await pinnedMessageManager.clear();
|
|
190
|
-
}
|
|
191
|
-
catch (err) {
|
|
192
|
-
logger.error("[Bot] Error clearing pinned message:", err);
|
|
193
|
-
}
|
|
194
|
-
// Initialize keyboard manager if not already
|
|
195
|
-
if (ctx.chat) {
|
|
196
|
-
keyboardManager.initialize(ctx.api, ctx.chat.id);
|
|
197
|
-
}
|
|
198
|
-
// Refresh context limit for current model
|
|
199
|
-
await pinnedMessageManager.refreshContextLimit();
|
|
200
|
-
const contextLimit = pinnedMessageManager.getContextLimit();
|
|
201
|
-
// Reset context to 0 (no session selected) with current model's limit
|
|
202
|
-
keyboardManager.updateContext(0, contextLimit);
|
|
203
|
-
// Get current state for keyboard (with context = 0)
|
|
204
|
-
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
205
|
-
const currentModel = getStoredModel();
|
|
206
|
-
const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
|
|
207
|
-
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
208
|
-
keyboardManager.updateAgent(currentAgent);
|
|
209
|
-
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
|
|
210
175
|
const projectName = selectedProject.name || selectedProject.worktree;
|
|
176
|
+
logger.info(`[Bot] Project selected: ${projectName} (id: ${projectId})`);
|
|
177
|
+
const keyboard = await switchToProject(ctx, selectedProject, "project_switched");
|
|
211
178
|
await ctx.answerCallbackQuery();
|
|
212
179
|
await ctx.reply(t("projects.selected", { project: projectName }), {
|
|
213
180
|
reply_markup: keyboard,
|
|
@@ -9,6 +9,7 @@ import { clearProject } from "../../settings/manager.js";
|
|
|
9
9
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
10
10
|
import { abortCurrentOperation } from "./abort.js";
|
|
11
11
|
import { t } from "../../i18n/index.js";
|
|
12
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
12
13
|
export async function startCommand(ctx) {
|
|
13
14
|
if (ctx.chat) {
|
|
14
15
|
if (!pinnedMessageManager.isInitialized()) {
|
|
@@ -18,6 +19,7 @@ export async function startCommand(ctx) {
|
|
|
18
19
|
}
|
|
19
20
|
await abortCurrentOperation(ctx, { notifyUser: false });
|
|
20
21
|
foregroundSessionState.clearAll("start_command_reset");
|
|
22
|
+
assistantRunState.clearAll("start_command_reset");
|
|
21
23
|
clearSession();
|
|
22
24
|
clearProject();
|
|
23
25
|
keyboardManager.clearContext();
|
|
@@ -3,7 +3,15 @@ import { logger } from "../../utils/logger.js";
|
|
|
3
3
|
import { t } from "../../i18n/index.js";
|
|
4
4
|
const INLINE_MENU_CANCEL_PREFIX = "inline:cancel:";
|
|
5
5
|
const LEGACY_CONTEXT_CANCEL_CALLBACK = "compact:cancel";
|
|
6
|
-
const INLINE_MENU_KINDS = [
|
|
6
|
+
const INLINE_MENU_KINDS = [
|
|
7
|
+
"project",
|
|
8
|
+
"session",
|
|
9
|
+
"model",
|
|
10
|
+
"agent",
|
|
11
|
+
"variant",
|
|
12
|
+
"context",
|
|
13
|
+
"open",
|
|
14
|
+
];
|
|
7
15
|
function isInlineMenuKind(value) {
|
|
8
16
|
return INLINE_MENU_KINDS.includes(value);
|
|
9
17
|
}
|