Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ OPENCODE_MODEL_ID=big-pickle
# Maximum number of scheduled tasks allowed at once (default: 10)
# TASK_LIMIT=10

# Stream update throttle in milliseconds for assistant/tool message edits (default: 500)
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
# RESPONSE_STREAM_THROTTLE_MS=500

# Bot locale: supported locale code (default: en)
# Supported locales: en, de, es, fr, ru, zh
# BOT_LOCALE=en
Expand Down
49 changes: 25 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,30 +147,31 @@ When installed via npm, the configuration wizard handles the initial setup. The
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
- **Linux:** `~/.config/opencode-telegram-bot/.env`

| Variable | Description | Required | Default |
| -------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
| `STT_API_KEY` | API key for your STT provider | No | — |
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
| Variable | Description | Required | Default |
| ----------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
| `RESPONSE_STREAM_THROTTLE_MS` | Stream edit throttle (ms) for assistant and tool updates | No | `500` |
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
| `STT_API_KEY` | API key for your STT provider | No | — |
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |

> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.

Expand Down
25 changes: 20 additions & 5 deletions src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { getCurrentSession } from "../session/manager.js";
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
import { logger } from "../utils/logger.js";
import { safeBackgroundTask } from "../utils/safe-background-task.js";
import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
import { pinnedMessageManager } from "../pinned/manager.js";
import { t } from "../i18n/index.js";
import { processUserPrompt } from "./handlers/prompt.js";
Expand Down Expand Up @@ -87,7 +88,7 @@ let chatIdInstance: number | null = null;
let commandsInitialized = false;

const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
const RESPONSE_STREAM_THROTTLE_MS = 200;
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
const SESSION_RETRY_PREFIX = "🔁";
const SUBAGENT_STREAM_PREFIX = "🧩";
Expand Down Expand Up @@ -128,7 +129,7 @@ function prepareStreamingPayload(messageText: string): StreamingMessagePayload |

return {
parts,
format: config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw",
format: "raw",
};
}

Expand Down Expand Up @@ -409,13 +410,15 @@ async function ensureEventSubscription(directory: string): Promise<void> {
]).then(() => undefined),
prepareStreamingPayload,
formatSummary,
formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
getReplyKeyboard: getCurrentReplyKeyboard,
sendText: async (text, options, format) => {
sendText: async (text, rawFallbackText, options, format) => {
await sendBotText({
api: botApi,
chatId,
text,
rawFallbackText,
options: options as Parameters<typeof sendBotText>[0]["options"],
format,
});
Expand Down Expand Up @@ -828,10 +831,22 @@ export function createBot(): Bot<Context> {
const timeSinceLast = now - lastGetUpdatesTime;
logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
lastGetUpdatesTime = now;
} else if (method === "sendMessage") {
return prev(method, payload, signal);
}

if (method === "sendMessage") {
logger.debug(`[Bot API] sendMessage to chat ${(payload as { chat_id?: number }).chat_id}`);
}
return prev(method, payload, signal);

return withTelegramRateLimitRetry(() => prev(method, payload, signal), {
maxRetries: 5,
onRetry: ({ attempt, retryAfterMs, error }) => {
logger.warn(
`[Bot API] Telegram rate limit on ${method}, retrying in ${retryAfterMs}ms (attempt=${attempt})`,
error,
);
},
});
});

bot.use((ctx, next) => {
Expand Down
10 changes: 8 additions & 2 deletions src/bot/utils/finalize-assistant-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ interface FinalizeAssistantResponseOptions {
flushPendingServiceMessages: () => Promise<void>;
prepareStreamingPayload: (messageText: string) => StreamingMessagePayload | null;
formatSummary: (messageText: string) => string[];
formatRawSummary: (messageText: string) => string[];
resolveFormat: () => TelegramTextFormat;
getReplyKeyboard: () => unknown;
sendText: (
text: string,
rawFallbackText: string | undefined,
options: { reply_markup: unknown } | undefined,
format: TelegramTextFormat,
) => Promise<void>;
Expand All @@ -28,6 +30,7 @@ export async function finalizeAssistantResponse({
flushPendingServiceMessages,
prepareStreamingPayload,
formatSummary,
formatRawSummary,
resolveFormat,
getReplyKeyboard,
sendText,
Expand Down Expand Up @@ -67,12 +70,15 @@ export async function finalizeAssistantResponse({
}

const parts = formatSummary(messageText);
const rawParts = formatRawSummary(messageText);
const format = resolveFormat();

for (const part of parts) {
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
const part = parts[partIndex];
const rawFallbackText = rawParts[partIndex];
const keyboard = getReplyKeyboard();
const options = keyboard ? { reply_markup: keyboard } : undefined;
await sendText(part, options, format);
await sendText(part, rawFallbackText, options, format);
}

return false;
Expand Down
124 changes: 122 additions & 2 deletions src/bot/utils/send-with-markdown-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface SendMessageWithMarkdownFallbackParams {
api: SendMessageApi;
chatId: Parameters<SendMessageApi["sendMessage"]>[0];
text: string;
rawFallbackText?: string;
options?: TelegramSendMessageOptions;
parseMode?: "Markdown" | "MarkdownV2";
}
Expand All @@ -19,6 +20,7 @@ interface EditMessageWithMarkdownFallbackParams {
chatId: Parameters<EditMessageApi["editMessageText"]>[0];
messageId: Parameters<EditMessageApi["editMessageText"]>[1];
text: string;
rawFallbackText?: string;
options?: TelegramEditMessageOptions;
parseMode?: "Markdown" | "MarkdownV2";
}
Expand All @@ -31,6 +33,58 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
"bad request: can't parse",
];

const MARKDOWN_V2_RESERVED_CHARS = new Set([
"_",
"*",
"[",
"]",
"(",
")",
"~",
"`",
">",
"#",
"+",
"-",
"=",
"|",
"{",
"}",
".",
"!",
"\\",
]);
const MARKDOWN_V2_ESCAPED_CHAR = /\\([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;

function escapeTelegramMarkdownV2(text: string): string {
let result = "";
let trailingBackslashes = 0;

for (const char of text) {
if (char === "\\") {
result += char;
trailingBackslashes += 1;
continue;
}

const isEscaped = trailingBackslashes % 2 === 1;
trailingBackslashes = 0;

if (MARKDOWN_V2_RESERVED_CHARS.has(char) && !isEscaped) {
result += `\\${char}`;
continue;
}

result += char;
}

return result;
}

function unescapeTelegramMarkdownV2(text: string): string {
return text.replace(MARKDOWN_V2_ESCAPED_CHAR, "$1");
}

function stripMarkdownFormattingOptions<
T extends TelegramSendMessageOptions | TelegramEditMessageOptions | undefined,
>(options: T): T {
Expand Down Expand Up @@ -94,6 +148,7 @@ export async function sendMessageWithMarkdownFallback({
api,
chatId,
text,
rawFallbackText,
options,
parseMode,
}: SendMessageWithMarkdownFallbackParams): Promise<
Expand All @@ -108,15 +163,42 @@ export async function sendMessageWithMarkdownFallback({
parse_mode: parseMode,
};

const fallbackText =
rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);

try {
return await api.sendMessage(chatId, text, markdownOptions);
} catch (error) {
if (!isTelegramMarkdownParseError(error)) {
throw error;
}

if (parseMode === "MarkdownV2") {
const escapedText = escapeTelegramMarkdownV2(text);
if (escapedText !== text) {
logger.warn(
"[Bot] Markdown parse failed, retrying assistant message with escaped MarkdownV2",
error,
);

try {
return await api.sendMessage(chatId, escapedText, markdownOptions);
} catch (escapedError) {
if (!isTelegramMarkdownParseError(escapedError)) {
throw escapedError;
}

logger.warn(
"[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode",
escapedError,
);
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
}
}
}

logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
}
}

Expand All @@ -125,6 +207,7 @@ export async function editMessageWithMarkdownFallback({
chatId,
messageId,
text,
rawFallbackText,
options,
parseMode,
}: EditMessageWithMarkdownFallbackParams): Promise<
Expand All @@ -139,14 +222,51 @@ export async function editMessageWithMarkdownFallback({
parse_mode: parseMode,
};

const fallbackText =
rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);

try {
return await api.editMessageText(chatId, messageId, text, markdownOptions);
} catch (error) {
if (!isTelegramMarkdownParseError(error)) {
throw error;
}

if (parseMode === "MarkdownV2") {
const escapedText = escapeTelegramMarkdownV2(text);
if (escapedText !== text) {
logger.warn(
"[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2",
error,
);

try {
return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
} catch (escapedError) {
if (!isTelegramMarkdownParseError(escapedError)) {
throw escapedError;
}

logger.warn(
"[Bot] Escaped Markdown parse failed, retrying edited message in raw mode",
escapedError,
);
return api.editMessageText(
chatId,
messageId,
fallbackText,
stripMarkdownFormattingOptions(options),
);
}
}
}

logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
return api.editMessageText(
chatId,
messageId,
fallbackText,
stripMarkdownFormattingOptions(options),
);
}
}
Loading
Loading