@grinev/opencode-telegram-bot 0.14.1 → 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 +25 -0
- package/README.md +50 -26
- package/dist/agent/manager.js +34 -6
- package/dist/agent/types.js +7 -1
- 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 +12 -2
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/new.js +3 -2
- package/dist/bot/commands/open.js +314 -0
- package/dist/bot/commands/projects.js +5 -37
- package/dist/bot/commands/sessions.js +3 -0
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/commands/status.js +5 -2
- package/dist/bot/commands/tts.js +13 -0
- package/dist/bot/handlers/agent.js +3 -4
- package/dist/bot/handlers/inline-menu.js +9 -1
- package/dist/bot/handlers/model.js +3 -2
- package/dist/bot/handlers/prompt.js +33 -5
- package/dist/bot/handlers/variant.js +3 -2
- package/dist/bot/index.js +179 -94
- package/dist/bot/message-patterns.js +2 -2
- package/dist/bot/streaming/response-streamer.js +26 -17
- package/dist/bot/streaming/tool-call-streamer.js +31 -24
- 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/keyboard.js +6 -6
- package/dist/bot/utils/send-tts-response.js +37 -0
- 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 +10 -0
- package/dist/i18n/de.js +32 -9
- package/dist/i18n/en.js +32 -9
- package/dist/i18n/es.js +32 -9
- package/dist/i18n/fr.js +32 -9
- package/dist/i18n/ru.js +32 -9
- package/dist/i18n/zh.js +32 -9
- package/dist/index.js +2 -0
- package/dist/pinned/manager.js +66 -4
- package/dist/project/manager.js +2 -1
- package/dist/settings/manager.js +7 -0
- package/dist/summary/aggregator.js +17 -1
- package/dist/summary/formatter.js +12 -89
- 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/tts/client.js +59 -0
- package/dist/utils/logger.js +200 -73
- package/package.json +6 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
2
|
const TELEGRAM_MESSAGE_SAFE_LENGTH = 4000;
|
|
3
|
+
const DEFAULT_STREAM_KEY = "default";
|
|
3
4
|
function getErrorMessage(error) {
|
|
4
5
|
if (error instanceof Error) {
|
|
5
6
|
return error.message;
|
|
@@ -68,23 +69,23 @@ export class ToolCallStreamer {
|
|
|
68
69
|
this.editText = options.editText;
|
|
69
70
|
this.deleteText = options.deleteText;
|
|
70
71
|
}
|
|
71
|
-
append(sessionId, text) {
|
|
72
|
+
append(sessionId, text, streamKey = DEFAULT_STREAM_KEY) {
|
|
72
73
|
const normalizedText = text.trim();
|
|
73
74
|
if (!sessionId || !normalizedText) {
|
|
74
75
|
return;
|
|
75
76
|
}
|
|
76
|
-
const state = this.getOrCreateState(sessionId);
|
|
77
|
+
const state = this.getOrCreateState(sessionId, streamKey);
|
|
77
78
|
state.entries.push({ text: normalizedText });
|
|
78
79
|
state.latestParts = buildParts(state.entries);
|
|
79
80
|
this.ensureTimer(state);
|
|
80
81
|
}
|
|
81
|
-
replaceByPrefix(sessionId, prefix, text) {
|
|
82
|
+
replaceByPrefix(sessionId, prefix, text, streamKey = DEFAULT_STREAM_KEY) {
|
|
82
83
|
const normalizedPrefix = prefix.trim();
|
|
83
84
|
const normalizedText = text.trim();
|
|
84
85
|
if (!sessionId || !normalizedPrefix || !normalizedText) {
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
|
-
const state = this.getOrCreateState(sessionId);
|
|
88
|
+
const state = this.getOrCreateState(sessionId, streamKey);
|
|
88
89
|
const existingEntry = state.entries.find((entry) => entry.prefix === normalizedPrefix);
|
|
89
90
|
if (existingEntry) {
|
|
90
91
|
existingEntry.text = normalizedText;
|
|
@@ -96,24 +97,21 @@ export class ToolCallStreamer {
|
|
|
96
97
|
this.ensureTimer(state);
|
|
97
98
|
}
|
|
98
99
|
async flushSession(sessionId, reason) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await this.enqueueTask(state, () => this.syncState(state, reason));
|
|
100
|
+
const states = this.getStatesForSession(sessionId);
|
|
101
|
+
await Promise.all(states.map(async (state) => {
|
|
102
|
+
this.clearTimer(state);
|
|
103
|
+
await this.enqueueTask(state, () => this.syncState(state, reason));
|
|
104
|
+
}));
|
|
105
105
|
}
|
|
106
106
|
async breakSession(sessionId, reason) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
const states = this.getStatesForSession(sessionId);
|
|
108
|
+
for (const state of states) {
|
|
109
|
+
state.isBreaking = true;
|
|
110
|
+
this.clearTimer(state);
|
|
111
|
+
await this.enqueueTask(state, () => this.syncState(state, reason));
|
|
112
|
+
this.cancelState(state);
|
|
113
|
+
this.removeState(state);
|
|
110
114
|
}
|
|
111
|
-
state.isBreaking = true;
|
|
112
|
-
this.getOrCreateState(sessionId);
|
|
113
|
-
this.clearTimer(state);
|
|
114
|
-
await this.enqueueTask(state, () => this.syncState(state, reason));
|
|
115
|
-
this.cancelState(state);
|
|
116
|
-
this.removeState(state);
|
|
117
115
|
logger.debug(`[ToolCallStreamer] Broke session stream: session=${sessionId}, reason=${reason}`);
|
|
118
116
|
}
|
|
119
117
|
clearSession(sessionId, reason) {
|
|
@@ -141,8 +139,15 @@ export class ToolCallStreamer {
|
|
|
141
139
|
logger.debug(`[ToolCallStreamer] Cleared all streams: count=${count}, reason=${reason}`);
|
|
142
140
|
}
|
|
143
141
|
}
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
getStateId(sessionId, streamKey) {
|
|
143
|
+
return `${sessionId}:${streamKey}`;
|
|
144
|
+
}
|
|
145
|
+
getStatesForSession(sessionId) {
|
|
146
|
+
return Array.from(this.allStates).filter((state) => state.sessionId === sessionId);
|
|
147
|
+
}
|
|
148
|
+
getOrCreateState(sessionId, streamKey = DEFAULT_STREAM_KEY) {
|
|
149
|
+
const stateId = this.getStateId(sessionId, streamKey);
|
|
150
|
+
const existing = this.states.get(stateId);
|
|
146
151
|
if (existing && !existing.isBroken && !existing.cancelled && !existing.isBreaking) {
|
|
147
152
|
return existing;
|
|
148
153
|
}
|
|
@@ -151,6 +156,7 @@ export class ToolCallStreamer {
|
|
|
151
156
|
this.removeState(existing);
|
|
152
157
|
}
|
|
153
158
|
const state = {
|
|
159
|
+
key: streamKey,
|
|
154
160
|
sessionId,
|
|
155
161
|
entries: [],
|
|
156
162
|
latestParts: [],
|
|
@@ -164,7 +170,7 @@ export class ToolCallStreamer {
|
|
|
164
170
|
fatalErrorMessage: null,
|
|
165
171
|
fatalErrorLogged: false,
|
|
166
172
|
};
|
|
167
|
-
this.states.set(
|
|
173
|
+
this.states.set(stateId, state);
|
|
168
174
|
this.allStates.add(state);
|
|
169
175
|
return state;
|
|
170
176
|
}
|
|
@@ -202,8 +208,9 @@ export class ToolCallStreamer {
|
|
|
202
208
|
this.clearTimer(state);
|
|
203
209
|
}
|
|
204
210
|
removeState(state) {
|
|
205
|
-
|
|
206
|
-
|
|
211
|
+
const stateId = this.getStateId(state.sessionId, state.key);
|
|
212
|
+
if (this.states.get(stateId) === state) {
|
|
213
|
+
this.states.delete(stateId);
|
|
207
214
|
}
|
|
208
215
|
this.allStates.delete(state);
|
|
209
216
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { config } from "../../config.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
import { chunkTelegramRenderedBlocks } from "../../telegram/render/chunker.js";
|
|
4
|
+
import { renderTelegramBlocks, renderTelegramParts } from "../../telegram/render/pipeline.js";
|
|
5
|
+
export function createPlainRenderedBlock(text) {
|
|
6
|
+
return {
|
|
7
|
+
blockType: "plain",
|
|
8
|
+
mode: "plain",
|
|
9
|
+
text,
|
|
10
|
+
fallbackText: text,
|
|
11
|
+
source: "plain",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function createPlainRenderedParts(text, maxPartLength) {
|
|
15
|
+
return chunkTelegramRenderedBlocks([createPlainRenderedBlock(text)], { maxPartLength });
|
|
16
|
+
}
|
|
17
|
+
function useAssistantEntitiesFormat() {
|
|
18
|
+
return config.bot.messageFormatMode === "markdown";
|
|
19
|
+
}
|
|
20
|
+
function renderAssistantBlocksSafe(text) {
|
|
21
|
+
if (!text) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return renderTelegramBlocks(text);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
logger.warn("[AssistantRender] Block rendering failed, falling back to plain streaming block", error);
|
|
29
|
+
return [createPlainRenderedBlock(text)];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function renderAssistantFinalPartsSafe(text, maxPartLength = 4096) {
|
|
33
|
+
if (!text) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const formatMode = useAssistantEntitiesFormat() ? "entities" : "raw";
|
|
37
|
+
if (!useAssistantEntitiesFormat()) {
|
|
38
|
+
const parts = createPlainRenderedParts(text, maxPartLength);
|
|
39
|
+
logger.debug("[AssistantRender] Built final assistant parts in raw mode", {
|
|
40
|
+
formatMode,
|
|
41
|
+
textLength: text.length,
|
|
42
|
+
partCount: parts.length,
|
|
43
|
+
});
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const parts = renderTelegramParts(text, { maxPartLength });
|
|
48
|
+
logger.debug("[AssistantRender] Built final assistant parts in entities mode", {
|
|
49
|
+
formatMode,
|
|
50
|
+
textLength: text.length,
|
|
51
|
+
partCount: parts.length,
|
|
52
|
+
richParts: parts.filter((part) => part.source === "entities").length,
|
|
53
|
+
plainParts: parts.filter((part) => part.source === "plain").length,
|
|
54
|
+
});
|
|
55
|
+
return parts;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger.warn("[AssistantRender] Part rendering failed, falling back to plain text parts", error);
|
|
59
|
+
const parts = createPlainRenderedParts(text, maxPartLength);
|
|
60
|
+
logger.debug("[AssistantRender] Built final assistant parts in raw fallback mode", {
|
|
61
|
+
formatMode,
|
|
62
|
+
textLength: text.length,
|
|
63
|
+
partCount: parts.length,
|
|
64
|
+
});
|
|
65
|
+
return parts;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function getStableStreamingBoundary(messageText) {
|
|
69
|
+
if (!messageText) {
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if (messageText.endsWith("\n\n")) {
|
|
73
|
+
return messageText.length;
|
|
74
|
+
}
|
|
75
|
+
const lastBlockSeparatorIndex = messageText.lastIndexOf("\n\n");
|
|
76
|
+
return lastBlockSeparatorIndex >= 0 ? lastBlockSeparatorIndex + 2 : 0;
|
|
77
|
+
}
|
|
78
|
+
export function prepareAssistantStreamingPayload(messageText, maxPartLength) {
|
|
79
|
+
if (!messageText) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const formatMode = useAssistantEntitiesFormat() ? "entities" : "raw";
|
|
83
|
+
if (!useAssistantEntitiesFormat()) {
|
|
84
|
+
const parts = createPlainRenderedParts(messageText, maxPartLength);
|
|
85
|
+
logger.debug("[AssistantRender] Built streaming assistant payload in raw mode", {
|
|
86
|
+
formatMode,
|
|
87
|
+
textLength: messageText.length,
|
|
88
|
+
partCount: parts.length,
|
|
89
|
+
});
|
|
90
|
+
return parts.length > 0 ? { parts } : null;
|
|
91
|
+
}
|
|
92
|
+
const stableBoundary = getStableStreamingBoundary(messageText);
|
|
93
|
+
const blocks = [];
|
|
94
|
+
if (stableBoundary > 0) {
|
|
95
|
+
blocks.push(...renderAssistantBlocksSafe(messageText.slice(0, stableBoundary)));
|
|
96
|
+
}
|
|
97
|
+
const unstableTail = stableBoundary > 0 ? messageText.slice(stableBoundary) : messageText;
|
|
98
|
+
if (unstableTail) {
|
|
99
|
+
blocks.push(createPlainRenderedBlock(unstableTail));
|
|
100
|
+
}
|
|
101
|
+
const parts = chunkTelegramRenderedBlocks(blocks, { maxPartLength });
|
|
102
|
+
logger.debug("[AssistantRender] Built streaming assistant payload in entities mode", {
|
|
103
|
+
formatMode,
|
|
104
|
+
textLength: messageText.length,
|
|
105
|
+
stableBoundary,
|
|
106
|
+
tailLength: unstableTail.length,
|
|
107
|
+
blockCount: blocks.length,
|
|
108
|
+
partCount: parts.length,
|
|
109
|
+
richParts: parts.filter((part) => part.source === "entities").length,
|
|
110
|
+
plainParts: parts.filter((part) => part.source === "plain").length,
|
|
111
|
+
});
|
|
112
|
+
return parts.length > 0 ? { parts } : null;
|
|
113
|
+
}
|
|
114
|
+
export function prepareAssistantFinalStreamingPayload(messageText, maxPartLength) {
|
|
115
|
+
const parts = renderAssistantFinalPartsSafe(messageText, maxPartLength);
|
|
116
|
+
return parts.length > 0 ? { parts } : null;
|
|
117
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getAgentDisplayName } from "../../agent/types.js";
|
|
2
|
+
function formatElapsedSeconds(elapsedMs) {
|
|
3
|
+
const safeElapsedMs = Math.max(0, elapsedMs);
|
|
4
|
+
return `${(safeElapsedMs / 1000).toFixed(1)}s`;
|
|
5
|
+
}
|
|
6
|
+
export function formatAssistantRunFooter({ agent, providerID, modelID, elapsedMs, }) {
|
|
7
|
+
const agentDisplay = getAgentDisplayName(agent);
|
|
8
|
+
return `${agentDisplay} · 🤖 ${providerID}/${modelID} · 🕒 ${formatElapsedSeconds(elapsedMs)}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { logger } from "../../utils/logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* Allowed root directories for the `/open` command.
|
|
7
|
+
*
|
|
8
|
+
* Configured via `OPEN_BROWSER_ROOTS` env var (comma-separated absolute paths).
|
|
9
|
+
* Defaults to `[os.homedir()]` when the variable is not set.
|
|
10
|
+
*
|
|
11
|
+
* All navigation in the directory browser is restricted to stay within one
|
|
12
|
+
* of the configured roots. This prevents the bot from browsing arbitrary
|
|
13
|
+
* locations on the filesystem.
|
|
14
|
+
*/
|
|
15
|
+
let resolvedRoots = null;
|
|
16
|
+
function isWindows() {
|
|
17
|
+
return process.platform === "win32";
|
|
18
|
+
}
|
|
19
|
+
function resolveConfiguredPath(p) {
|
|
20
|
+
return path.resolve(expandTilde(p));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Expand a leading `~` or `~/` to the user's home directory.
|
|
24
|
+
* Does not handle `~user/` syntax — only the current user's home.
|
|
25
|
+
*/
|
|
26
|
+
function expandTilde(p) {
|
|
27
|
+
if (p === "~") {
|
|
28
|
+
return os.homedir();
|
|
29
|
+
}
|
|
30
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
31
|
+
return path.join(os.homedir(), p.slice(2));
|
|
32
|
+
}
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a path for comparison: expand tilde, resolve, then lowercase on Windows.
|
|
37
|
+
*/
|
|
38
|
+
function normalizePath(p) {
|
|
39
|
+
const resolved = resolveConfiguredPath(p);
|
|
40
|
+
return isWindows() ? resolved.toLowerCase() : resolved;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Initialize (or re-initialize) the allowed browser roots from the raw
|
|
44
|
+
* env string. Each entry is resolved to an absolute path via `path.resolve`.
|
|
45
|
+
*
|
|
46
|
+
* Entries that cannot be resolved are logged and skipped.
|
|
47
|
+
*/
|
|
48
|
+
export function initBrowserRoots(raw) {
|
|
49
|
+
if (!raw || raw.trim() === "") {
|
|
50
|
+
resolvedRoots = [resolveConfiguredPath(os.homedir())];
|
|
51
|
+
logger.debug(`[BrowserRoots] No OPEN_BROWSER_ROOTS configured, defaulting to home: ${resolvedRoots[0]}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const entries = raw
|
|
55
|
+
.split(",")
|
|
56
|
+
.map((s) => s.trim())
|
|
57
|
+
.filter((s) => s.length > 0);
|
|
58
|
+
const roots = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
roots.push(resolveConfiguredPath(entry));
|
|
61
|
+
}
|
|
62
|
+
if (roots.length === 0) {
|
|
63
|
+
resolvedRoots = [resolveConfiguredPath(os.homedir())];
|
|
64
|
+
logger.warn("[BrowserRoots] All configured roots were invalid, falling back to home directory");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
resolvedRoots = roots;
|
|
68
|
+
logger.info(`[BrowserRoots] Configured roots: ${roots.join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Return the configured browser roots. Lazily initializes from env if
|
|
73
|
+
* `initBrowserRoots` was never called.
|
|
74
|
+
*/
|
|
75
|
+
export function getBrowserRoots() {
|
|
76
|
+
if (resolvedRoots === null) {
|
|
77
|
+
initBrowserRoots(process.env.OPEN_BROWSER_ROOTS);
|
|
78
|
+
}
|
|
79
|
+
return resolvedRoots;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Return the original (non-normalized) root paths for display/navigation.
|
|
83
|
+
* On Windows the display roots are lowercased because `normalizePath` is
|
|
84
|
+
* used during init — this is acceptable for button labels.
|
|
85
|
+
*/
|
|
86
|
+
export function getBrowserRootPaths() {
|
|
87
|
+
return getBrowserRoots();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Check whether a target path is inside one of the allowed roots.
|
|
91
|
+
*
|
|
92
|
+
* Uses `path.resolve` on the target for consistent comparison, then
|
|
93
|
+
* checks that the target equals a root or is a descendant (starts with
|
|
94
|
+
* root + separator).
|
|
95
|
+
*
|
|
96
|
+
* For full symlink-proof validation, callers can optionally resolve the
|
|
97
|
+
* target with `fs.realpath` before calling this function.
|
|
98
|
+
*/
|
|
99
|
+
export function isWithinAllowedRoot(targetPath) {
|
|
100
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
101
|
+
const roots = getBrowserRoots();
|
|
102
|
+
for (const root of roots) {
|
|
103
|
+
const normalizedRoot = normalizePath(root);
|
|
104
|
+
if (normalizedTarget === normalizedRoot) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
// Check both separators — `path.sep` is always `/` on Unix but a
|
|
108
|
+
// lowercased Windows path may contain either `\` or `/`.
|
|
109
|
+
if (normalizedTarget.startsWith(normalizedRoot + "/") ||
|
|
110
|
+
normalizedTarget.startsWith(normalizedRoot + "\\")) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Like `isWithinAllowedRoot` but resolves symlinks first using `realpath`.
|
|
118
|
+
* Falls back to the plain path if `realpath` fails (e.g. ENOENT).
|
|
119
|
+
*/
|
|
120
|
+
export async function isWithinAllowedRootSafe(targetPath) {
|
|
121
|
+
let resolved = targetPath;
|
|
122
|
+
try {
|
|
123
|
+
resolved = await realpath(targetPath);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Path doesn't exist yet or can't be resolved — use as-is
|
|
127
|
+
}
|
|
128
|
+
return isWithinAllowedRoot(resolved);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Check whether a path is exactly one of the allowed roots (not a descendant).
|
|
132
|
+
*/
|
|
133
|
+
export function isAllowedRoot(targetPath) {
|
|
134
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
135
|
+
return getBrowserRoots().some((root) => normalizePath(root) === normalizedTarget);
|
|
136
|
+
}
|
|
137
|
+
/** Reset state — for testing only. */
|
|
138
|
+
export function __resetBrowserRootsForTests() {
|
|
139
|
+
resolvedRoots = null;
|
|
140
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
export const MAX_ENTRIES_PER_PAGE = 8;
|
|
6
|
+
export function getHomeDirectory() {
|
|
7
|
+
return os.homedir();
|
|
8
|
+
}
|
|
9
|
+
export function pathToDisplayPath(absolutePath) {
|
|
10
|
+
const home = getHomeDirectory();
|
|
11
|
+
if (absolutePath === home) {
|
|
12
|
+
return "~";
|
|
13
|
+
}
|
|
14
|
+
if (absolutePath.startsWith(home + path.sep)) {
|
|
15
|
+
return "~" + absolutePath.slice(home.length);
|
|
16
|
+
}
|
|
17
|
+
return absolutePath;
|
|
18
|
+
}
|
|
19
|
+
export async function scanDirectory(dirPath, page = 0) {
|
|
20
|
+
try {
|
|
21
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
22
|
+
const subdirs = [];
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
25
|
+
subdirs.push({
|
|
26
|
+
name: entry.name,
|
|
27
|
+
fullPath: path.join(dirPath, entry.name),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
subdirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
32
|
+
const parentPath = path.dirname(dirPath);
|
|
33
|
+
const hasParent = dirPath !== path.parse(dirPath).root;
|
|
34
|
+
// Clamp page to valid range so stale or crafted callbacks never produce
|
|
35
|
+
// an out-of-bounds page indicator like "(4/2)".
|
|
36
|
+
const totalPages = Math.max(1, Math.ceil(subdirs.length / MAX_ENTRIES_PER_PAGE));
|
|
37
|
+
const safePage = Math.max(0, Math.min(page, totalPages - 1));
|
|
38
|
+
const start = safePage * MAX_ENTRIES_PER_PAGE;
|
|
39
|
+
const pageEntries = subdirs.slice(start, start + MAX_ENTRIES_PER_PAGE);
|
|
40
|
+
return {
|
|
41
|
+
entries: pageEntries,
|
|
42
|
+
totalCount: subdirs.length,
|
|
43
|
+
page: safePage,
|
|
44
|
+
currentPath: dirPath,
|
|
45
|
+
displayPath: pathToDisplayPath(dirPath),
|
|
46
|
+
hasParent,
|
|
47
|
+
parentPath: hasParent ? parentPath : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof Error) {
|
|
52
|
+
if ("code" in error) {
|
|
53
|
+
const code = error.code;
|
|
54
|
+
if (code === "ENOENT" || code === "ELOOP") {
|
|
55
|
+
return { error: `Directory not found: ${dirPath}`, code: "ENOENT" };
|
|
56
|
+
}
|
|
57
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
58
|
+
return { error: `Permission denied: ${dirPath}`, code: "EACCES" };
|
|
59
|
+
}
|
|
60
|
+
if (code === "ENOTDIR") {
|
|
61
|
+
return { error: `Not a directory: ${dirPath}`, code: "ENOTDIR" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
67
|
+
code: "UNKNOWN",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function buildEntryLabel(entry) {
|
|
72
|
+
return `📁 ${entry.name}`;
|
|
73
|
+
}
|
|
74
|
+
export function buildTreeHeader(displayPath, totalCount, page, totalPages) {
|
|
75
|
+
let header = `📂 ${displayPath}`;
|
|
76
|
+
if (totalPages > 1) {
|
|
77
|
+
header += ` (${page + 1}/${totalPages})`;
|
|
78
|
+
}
|
|
79
|
+
if (totalCount === 0) {
|
|
80
|
+
header += `\n${t("open.no_subfolders")}`;
|
|
81
|
+
}
|
|
82
|
+
else if (totalCount === 1) {
|
|
83
|
+
header += `\n${t("open.subfolder_count", { count: String(totalCount) })}`;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
header += `\n${t("open.subfolders_count", { count: String(totalCount) })}`;
|
|
87
|
+
}
|
|
88
|
+
return header;
|
|
89
|
+
}
|
|
90
|
+
export function isScanError(result) {
|
|
91
|
+
return "error" in result;
|
|
92
|
+
}
|
|
@@ -1,35 +1,29 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
|
-
export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload,
|
|
3
|
-
|
|
2
|
+
export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, renderFinalParts, getReplyKeyboard, sendRenderedPart, }) {
|
|
3
|
+
logger.debug(`[FinalizeResponse] Final assistant raw text received: session=${sessionId}, message=${messageId}`, messageText);
|
|
4
|
+
const keyboard = getReplyKeyboard();
|
|
5
|
+
const replyOptions = keyboard ? { reply_markup: keyboard } : undefined;
|
|
6
|
+
const silentReplyOptions = {
|
|
7
|
+
disable_notification: true,
|
|
8
|
+
...(replyOptions ?? {}),
|
|
9
|
+
};
|
|
10
|
+
const streamSendOptions = {
|
|
11
|
+
...silentReplyOptions,
|
|
12
|
+
};
|
|
4
13
|
const preparedStreamPayload = prepareStreamingPayload(messageText);
|
|
5
14
|
if (preparedStreamPayload) {
|
|
6
|
-
preparedStreamPayload.sendOptions =
|
|
15
|
+
preparedStreamPayload.sendOptions = streamSendOptions;
|
|
7
16
|
preparedStreamPayload.editOptions = undefined;
|
|
8
17
|
}
|
|
9
18
|
const result = await responseStreamer.complete(sessionId, messageId, preparedStreamPayload ?? undefined);
|
|
10
|
-
if (result.streamed) {
|
|
11
|
-
streamedMessageIds = result.telegramMessageIds;
|
|
12
|
-
}
|
|
13
19
|
await flushPendingServiceMessages();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
await deleteMessages(streamedMessageIds);
|
|
19
|
-
}
|
|
20
|
-
catch (err) {
|
|
21
|
-
logger.warn("[FinalizeResponse] Failed to delete streamed messages, sending with keyboard anyway:", err);
|
|
22
|
-
}
|
|
20
|
+
if (result.streamed) {
|
|
21
|
+
logger.debug(`[FinalizeResponse] Finalized streamed assistant message in place: session=${sessionId}, message=${messageId}, telegramMessages=${result.telegramMessageIds.length}`);
|
|
22
|
+
return true;
|
|
23
23
|
}
|
|
24
|
-
const parts =
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
|
|
28
|
-
const part = parts[partIndex];
|
|
29
|
-
const rawFallbackText = rawParts[partIndex];
|
|
30
|
-
const keyboard = getReplyKeyboard();
|
|
31
|
-
const options = keyboard ? { reply_markup: keyboard } : undefined;
|
|
32
|
-
await sendText(part, rawFallbackText, options, format);
|
|
24
|
+
const parts = renderFinalParts(messageText);
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
await sendRenderedPart(part, silentReplyOptions);
|
|
33
27
|
}
|
|
34
28
|
return false;
|
|
35
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Keyboard } from "grammy";
|
|
2
|
-
import {
|
|
2
|
+
import { getAgentButtonLabel } from "../../agent/types.js";
|
|
3
3
|
import { formatModelForButton } from "../../model/types.js";
|
|
4
4
|
import { t } from "../../i18n/index.js";
|
|
5
5
|
/**
|
|
@@ -33,7 +33,7 @@ function formatContextForButton(contextInfo) {
|
|
|
33
33
|
*/
|
|
34
34
|
export function createMainKeyboard(currentAgent, currentModel, contextInfo, variantName) {
|
|
35
35
|
const keyboard = new Keyboard();
|
|
36
|
-
const agentText =
|
|
36
|
+
const agentText = getAgentButtonLabel(currentAgent);
|
|
37
37
|
// Format model with compact provider/model text and icon
|
|
38
38
|
const modelText = formatModelForButton(currentModel.providerID, currentModel.modelID);
|
|
39
39
|
// Context text - show "0" if no data available
|
|
@@ -49,15 +49,15 @@ export function createMainKeyboard(currentAgent, currentModel, contextInfo, vari
|
|
|
49
49
|
return keyboard.resized().persistent();
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* Create Reply Keyboard with agent
|
|
52
|
+
* Create Reply Keyboard with agent indicator
|
|
53
53
|
* @param currentAgent Current agent name (e.g., "build", "plan")
|
|
54
|
-
* @returns Reply Keyboard with single button showing current
|
|
54
|
+
* @returns Reply Keyboard with single button showing current agent
|
|
55
55
|
* @deprecated Use createMainKeyboard instead
|
|
56
56
|
*/
|
|
57
57
|
export function createAgentKeyboard(currentAgent) {
|
|
58
58
|
const keyboard = new Keyboard();
|
|
59
|
-
const displayName =
|
|
60
|
-
// Single button with current agent
|
|
59
|
+
const displayName = getAgentButtonLabel(currentAgent);
|
|
60
|
+
// Single button with current agent
|
|
61
61
|
keyboard.text(displayName).row();
|
|
62
62
|
return keyboard.resized().persistent();
|
|
63
63
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { InputFile } from "grammy";
|
|
2
|
+
import { consumePromptResponseMode } from "../handlers/prompt.js";
|
|
3
|
+
import { isTtsConfigured, synthesizeSpeech } from "../../tts/client.js";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
6
|
+
const MAX_TTS_INPUT_CHARS = 4_000;
|
|
7
|
+
export async function sendTtsResponseForSession({ api, sessionId, chatId, text, consumeResponseMode: consumeResponseModeImpl = consumePromptResponseMode, isTtsConfigured: isTtsConfiguredImpl = isTtsConfigured, synthesizeSpeech: synthesizeSpeechImpl = synthesizeSpeech, }) {
|
|
8
|
+
const responseMode = consumeResponseModeImpl(sessionId);
|
|
9
|
+
if (responseMode !== "text_and_tts") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const normalizedText = text.trim();
|
|
13
|
+
if (!normalizedText) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (!isTtsConfiguredImpl()) {
|
|
17
|
+
logger.info(`[TTS] Skipping audio reply for session ${sessionId}: TTS is not configured`);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (normalizedText.length > MAX_TTS_INPUT_CHARS) {
|
|
21
|
+
logger.warn(`[TTS] Skipping audio reply for session ${sessionId}: text length ${normalizedText.length} exceeds limit ${MAX_TTS_INPUT_CHARS}`);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const speech = await synthesizeSpeechImpl(normalizedText);
|
|
26
|
+
await api.sendAudio(chatId, new InputFile(speech.buffer, speech.filename));
|
|
27
|
+
logger.info(`[TTS] Sent audio reply for session ${sessionId}`);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
logger.warn(`[TTS] Failed to send audio reply for session ${sessionId}`, error);
|
|
32
|
+
await api.sendMessage(chatId, t("tts.failed")).catch((sendError) => {
|
|
33
|
+
logger.warn(`[TTS] Failed to send audio error message for session ${sessionId}`, sendError);
|
|
34
|
+
});
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -110,7 +110,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFa
|
|
|
110
110
|
if (parseMode === "MarkdownV2") {
|
|
111
111
|
const escapedText = escapeTelegramMarkdownV2(text);
|
|
112
112
|
if (escapedText !== text) {
|
|
113
|
-
logger.warn("[Bot] Markdown parse failed, retrying
|
|
113
|
+
logger.warn("[Bot] Markdown parse failed, retrying message with escaped MarkdownV2", error);
|
|
114
114
|
try {
|
|
115
115
|
return await api.sendMessage(chatId, escapedText, markdownOptions);
|
|
116
116
|
}
|
|
@@ -118,12 +118,12 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFa
|
|
|
118
118
|
if (!isTelegramMarkdownParseError(escapedError)) {
|
|
119
119
|
throw escapedError;
|
|
120
120
|
}
|
|
121
|
-
logger.warn("[Bot] Escaped Markdown parse failed, retrying
|
|
121
|
+
logger.warn("[Bot] Escaped Markdown parse failed, retrying message in raw mode", escapedError);
|
|
122
122
|
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
-
logger.warn("[Bot] Markdown parse failed, retrying
|
|
126
|
+
logger.warn("[Bot] Markdown parse failed, retrying message in raw mode", error);
|
|
127
127
|
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
128
128
|
}
|
|
129
129
|
}
|