@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.
Files changed (45) hide show
  1. package/.env.example +13 -0
  2. package/README.md +6 -0
  3. package/dist/app/start-bot-app.js +6 -1
  4. package/dist/bot/assistant-run-state.js +60 -0
  5. package/dist/bot/commands/abort.js +2 -0
  6. package/dist/bot/commands/commands.js +10 -0
  7. package/dist/bot/commands/definitions.js +1 -0
  8. package/dist/bot/commands/open.js +314 -0
  9. package/dist/bot/commands/projects.js +5 -38
  10. package/dist/bot/commands/start.js +2 -0
  11. package/dist/bot/handlers/inline-menu.js +9 -1
  12. package/dist/bot/handlers/prompt.js +11 -0
  13. package/dist/bot/index.js +162 -98
  14. package/dist/bot/streaming/response-streamer.js +26 -17
  15. package/dist/bot/utils/assistant-rendering.js +117 -0
  16. package/dist/bot/utils/assistant-run-footer.js +9 -0
  17. package/dist/bot/utils/browser-roots.js +140 -0
  18. package/dist/bot/utils/file-tree.js +92 -0
  19. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  20. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  21. package/dist/bot/utils/switch-project.js +48 -0
  22. package/dist/bot/utils/telegram-text.js +95 -1
  23. package/dist/cli.js +4 -0
  24. package/dist/config.js +3 -0
  25. package/dist/i18n/de.js +15 -0
  26. package/dist/i18n/en.js +15 -0
  27. package/dist/i18n/es.js +15 -0
  28. package/dist/i18n/fr.js +15 -0
  29. package/dist/i18n/ru.js +15 -0
  30. package/dist/i18n/zh.js +15 -0
  31. package/dist/index.js +2 -0
  32. package/dist/project/manager.js +2 -1
  33. package/dist/summary/aggregator.js +17 -1
  34. package/dist/summary/formatter.js +2 -88
  35. package/dist/telegram/render/block-fallback.js +28 -0
  36. package/dist/telegram/render/block-parser.js +295 -0
  37. package/dist/telegram/render/block-renderer.js +457 -0
  38. package/dist/telegram/render/chunker.js +281 -0
  39. package/dist/telegram/render/inline-renderer.js +128 -0
  40. package/dist/telegram/render/markdown-normalizer.js +94 -0
  41. package/dist/telegram/render/pipeline.js +9 -0
  42. package/dist/telegram/render/types.js +1 -0
  43. package/dist/telegram/render/validator.js +160 -0
  44. package/dist/utils/logger.js +200 -73
  45. package/package.json +6 -2
@@ -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, formatSummary, formatRawSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
3
- let streamedMessageIds = [];
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 = { disable_notification: true };
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
- // When the response was streamed, delete the streamed messages and re-send
15
- // via the non-streamed path so the reply keyboard carries the latest context.
16
- if (streamedMessageIds.length > 0) {
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 = formatSummary(messageText);
25
- const rawParts = formatRawSummary(messageText);
26
- const format = resolveFormat();
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
  }
@@ -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 assistant message with escaped MarkdownV2", error);
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 assistant message in raw mode", escapedError);
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 assistant message in raw mode", error);
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
  }
@@ -0,0 +1,48 @@
1
+ import { setCurrentProject } from "../../settings/manager.js";
2
+ import { clearSession } from "../../session/manager.js";
3
+ import { summaryAggregator } from "../../summary/aggregator.js";
4
+ import { pinnedMessageManager } from "../../pinned/manager.js";
5
+ import { keyboardManager } from "../../keyboard/manager.js";
6
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
7
+ import { getStoredModel } from "../../model/manager.js";
8
+ import { formatVariantForButton } from "../../variant/manager.js";
9
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
10
+ import { createMainKeyboard } from "./keyboard.js";
11
+ import { logger } from "../../utils/logger.js";
12
+ /**
13
+ * Shared logic for switching the active project.
14
+ *
15
+ * Called by both `/projects` (selecting an existing project) and `/open`
16
+ * (browsing and adding a new directory). Performs the full state transition:
17
+ * persists the project, clears the session, resets the pinned message and
18
+ * keyboard, and returns a fresh reply keyboard for the caller to attach to
19
+ * its confirmation message.
20
+ *
21
+ * @param ctx grammY callback context (used for `ctx.chat` / `ctx.api`)
22
+ * @param project the project to switch to
23
+ * @param reason short tag for `clearAllInteractionState` (e.g. "project_switched")
24
+ */
25
+ export async function switchToProject(ctx, project, reason) {
26
+ setCurrentProject(project);
27
+ clearSession();
28
+ summaryAggregator.clear();
29
+ clearAllInteractionState(reason);
30
+ try {
31
+ await pinnedMessageManager.clear();
32
+ }
33
+ catch (err) {
34
+ logger.error("[Bot] Error clearing pinned message:", err);
35
+ }
36
+ if (ctx.chat) {
37
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
38
+ }
39
+ await pinnedMessageManager.refreshContextLimit();
40
+ const contextLimit = pinnedMessageManager.getContextLimit();
41
+ keyboardManager.updateContext(0, contextLimit);
42
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
43
+ const currentModel = getStoredModel();
44
+ const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
45
+ const variantName = formatVariantForButton(currentModel.variant || "default");
46
+ keyboardManager.updateAgent(currentAgent);
47
+ return createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
48
+ }
@@ -1,10 +1,25 @@
1
- import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./send-with-markdown-fallback.js";
1
+ import { logger } from "../../utils/logger.js";
2
+ import { editMessageWithMarkdownFallback, isTelegramMarkdownParseError, sendMessageWithMarkdownFallback, } from "./send-with-markdown-fallback.js";
2
3
  function resolveParseMode(format) {
3
4
  if (format === "markdown_v2") {
4
5
  return "MarkdownV2";
5
6
  }
6
7
  return undefined;
7
8
  }
9
+ function stripRichFormattingOptions(options) {
10
+ if (!options) {
11
+ return options;
12
+ }
13
+ const rawOptions = {
14
+ ...options,
15
+ };
16
+ delete rawOptions.parse_mode;
17
+ delete rawOptions.entities;
18
+ return rawOptions;
19
+ }
20
+ export function getTelegramRenderedPartSignature(part) {
21
+ return `${part.text}\n${JSON.stringify(part.entities ?? null)}`;
22
+ }
8
23
  export async function sendBotText({ api, chatId, text, rawFallbackText, options, format = "raw", }) {
9
24
  await sendMessageWithMarkdownFallback({
10
25
  api,
@@ -15,6 +30,85 @@ export async function sendBotText({ api, chatId, text, rawFallbackText, options,
15
30
  parseMode: resolveParseMode(format),
16
31
  });
17
32
  }
33
+ export async function sendRenderedBotPart({ api, chatId, part, options, }) {
34
+ const rawOptions = stripRichFormattingOptions(options);
35
+ logger.debug("[Bot] Sending rendered Telegram part", {
36
+ source: part.source,
37
+ textLength: part.text.length,
38
+ fallbackTextLength: part.fallbackText.length,
39
+ entityCount: part.entities?.length ?? 0,
40
+ });
41
+ if (!part.entities?.length) {
42
+ const sentMessage = await api.sendMessage(chatId, part.text, rawOptions);
43
+ return {
44
+ messageId: sentMessage.message_id,
45
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.text }),
46
+ };
47
+ }
48
+ try {
49
+ const sentMessage = await api.sendMessage(chatId, part.text, {
50
+ ...(rawOptions || {}),
51
+ entities: part.entities,
52
+ });
53
+ return {
54
+ messageId: sentMessage.message_id,
55
+ deliveredSignature: getTelegramRenderedPartSignature(part),
56
+ };
57
+ }
58
+ catch (error) {
59
+ if (!isTelegramMarkdownParseError(error)) {
60
+ throw error;
61
+ }
62
+ logger.warn("[Bot] Entity payload rejected, retrying assistant message part in raw mode", error);
63
+ const sentMessage = await api.sendMessage(chatId, part.fallbackText, rawOptions);
64
+ logger.debug("[Bot] Assistant message part sent in raw fallback mode", {
65
+ fallbackTextLength: part.fallbackText.length,
66
+ });
67
+ return {
68
+ messageId: sentMessage.message_id,
69
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.fallbackText }),
70
+ };
71
+ }
72
+ }
73
+ export async function editRenderedBotPart({ api, chatId, messageId, part, options, }) {
74
+ const rawOptions = stripRichFormattingOptions(options);
75
+ logger.debug("[Bot] Editing rendered Telegram part", {
76
+ messageId,
77
+ source: part.source,
78
+ textLength: part.text.length,
79
+ fallbackTextLength: part.fallbackText.length,
80
+ entityCount: part.entities?.length ?? 0,
81
+ });
82
+ if (!part.entities?.length) {
83
+ await api.editMessageText(chatId, messageId, part.text, rawOptions);
84
+ return {
85
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.text }),
86
+ };
87
+ }
88
+ try {
89
+ await api.editMessageText(chatId, messageId, part.text, {
90
+ ...(rawOptions || {}),
91
+ entities: part.entities,
92
+ });
93
+ return {
94
+ deliveredSignature: getTelegramRenderedPartSignature(part),
95
+ };
96
+ }
97
+ catch (error) {
98
+ if (!isTelegramMarkdownParseError(error)) {
99
+ throw error;
100
+ }
101
+ logger.warn("[Bot] Entity payload rejected, retrying assistant edit part in raw mode", error);
102
+ await api.editMessageText(chatId, messageId, part.fallbackText, rawOptions);
103
+ logger.debug("[Bot] Assistant edit part applied in raw fallback mode", {
104
+ messageId,
105
+ fallbackTextLength: part.fallbackText.length,
106
+ });
107
+ return {
108
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.fallbackText }),
109
+ };
110
+ }
111
+ }
18
112
  export async function editBotText({ api, chatId, messageId, text, rawFallbackText, options, format = "raw", }) {
19
113
  await editMessageWithMarkdownFallback({
20
114
  api,
package/dist/cli.js CHANGED
@@ -32,6 +32,8 @@ async function runStartCommand(mode) {
32
32
  throw new Error(modeResult.error);
33
33
  }
34
34
  setRuntimeMode(modeResult.mode);
35
+ const { initializeLogger } = await import("./utils/logger.js");
36
+ await initializeLogger();
35
37
  const { ensureRuntimeConfigForStart } = await import("./runtime/bootstrap.js");
36
38
  await ensureRuntimeConfigForStart();
37
39
  const { startBotApp } = await import("./app/start-bot-app.js");
@@ -40,6 +42,8 @@ async function runStartCommand(mode) {
40
42
  }
41
43
  async function runConfigCommand() {
42
44
  setRuntimeMode("installed");
45
+ const { initializeLogger } = await import("./utils/logger.js");
46
+ await initializeLogger();
43
47
  const { runConfigWizardCommand } = await import("./runtime/bootstrap.js");
44
48
  await runConfigWizardCommand();
45
49
  return EXIT_SUCCESS;
package/dist/config.js CHANGED
@@ -83,6 +83,9 @@ export const config = {
83
83
  files: {
84
84
  maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
85
85
  },
86
+ open: {
87
+ browserRoots: getEnvVar("OPEN_BROWSER_ROOTS", false),
88
+ },
86
89
  stt: {
87
90
  apiUrl: getEnvVar("STT_API_URL", false),
88
91
  apiKey: getEnvVar("STT_API_KEY", false),
package/dist/i18n/de.js CHANGED
@@ -345,4 +345,19 @@ export const de = {
345
345
  "stt.not_configured": "🎤 Spracherkennung ist nicht konfiguriert.\n\nSetze STT_API_URL und STT_API_KEY in .env, um sie zu aktivieren.",
346
346
  "stt.error": "🔴 Audio konnte nicht erkannt werden: {error}",
347
347
  "stt.empty_result": "🎤 Keine Sprache in der Audionachricht erkannt.",
348
+ "cmd.description.open": "Projekt durch Ordner-Auswahl hinzufügen",
349
+ "open.back": "⬆️ Hoch",
350
+ "open.roots": "📋 Zurück zur Auswahl",
351
+ "open.prev_page": "⬅️ Zurück",
352
+ "open.next_page": "Weiter ➡️",
353
+ "open.select_current": "✅ Diesen Ordner wählen",
354
+ "open.select_root": "📂 Stammverzeichnis zum Durchsuchen wählen:",
355
+ "open.access_denied": "⛔ Zugriff verweigert: Pfad liegt außerhalb erlaubter Verzeichnisse",
356
+ "open.scan_error": "🔴 Verzeichnis kann nicht durchsucht werden: {error}",
357
+ "open.open_error": "🔴 Verzeichnisbrowser konnte nicht geöffnet werden.",
358
+ "open.selected": "✅ Projekt hinzugefügt: {project}\n\n📋 Verwende /sessions oder /new zum Arbeiten.",
359
+ "open.select_error": "🔴 Projekt konnte nicht hinzugefügt werden.",
360
+ "open.no_subfolders": "📭 Keine Unterordner",
361
+ "open.subfolder_count": "{count} Unterordner",
362
+ "open.subfolders_count": "{count} Unterordner",
348
363
  };
package/dist/i18n/en.js CHANGED
@@ -345,4 +345,19 @@ export const en = {
345
345
  "stt.not_configured": "🎤 Voice recognition is not configured.\n\nSet STT_API_URL and STT_API_KEY in .env to enable it.",
346
346
  "stt.error": "🔴 Failed to recognize audio: {error}",
347
347
  "stt.empty_result": "🎤 No speech detected in the audio message.",
348
+ "cmd.description.open": "Add a project by browsing directories",
349
+ "open.back": "⬆️ Up",
350
+ "open.roots": "📋 Back to roots",
351
+ "open.prev_page": "⬅️ Previous",
352
+ "open.next_page": "Next ➡️",
353
+ "open.select_current": "✅ Select this folder",
354
+ "open.select_root": "📂 Select a root directory to browse:",
355
+ "open.access_denied": "⛔ Access denied: path is outside allowed roots",
356
+ "open.scan_error": "🔴 Cannot browse directory: {error}",
357
+ "open.open_error": "🔴 Failed to open directory browser.",
358
+ "open.selected": "✅ Project added: {project}\n\n📋 Use /sessions or /new to start working.",
359
+ "open.select_error": "🔴 Failed to add project.",
360
+ "open.no_subfolders": "📭 No subfolders",
361
+ "open.subfolder_count": "{count} subfolder",
362
+ "open.subfolders_count": "{count} subfolders",
348
363
  };