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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ OPENCODE_MODEL_ID=big-pickle
# BASH_TOOL_DISPLAY_MAX_LENGTH=128

# Bot locale: supported locale code (default: en)
# Supported locales: en, ar, de, es, fr, it, ko, pt, ru, zh
# Supported locales: en, ar, de, es, fr, it, ko, pt, ru, tr, zh
# BOT_LOCALE=en

# Track background sessions in the currently selected project/worktree (default: true)
Expand All @@ -125,6 +125,7 @@ OPENCODE_MODEL_ID=big-pickle
# any setting the user has already changed via /settings is left untouched.
# Supported keys: ttsMode ("off"|"all"|"auto"), compactOutputMode (bool),
# showThinkingContent (bool), showAssistantRunFooter (bool),
# pinnedDashboardEnabled (bool),
# responseStreamingMode ("edit"|"draft"), sendDiffFileAttachments (bool),
# promptQueueEnabled (bool)
# The preset is validated at startup: invalid JSON, a non-object value, unknown
Expand Down
11 changes: 7 additions & 4 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ No public inbound ports are required for normal usage.
### Result delivery

- Send each completed assistant response after completion signal from SSE
- Show elapsed time for tool calls running longer than 20 seconds, updated on a timer so it keeps counting while a tool blocks without producing output; covers subagent cards and compact mode, and the total duration stays on the finished tool line. A finished subagent card keeps the time its whole run took. Durations use the same `· 🕒 1h 2m 3s` format as the assistant run footer
- Compact output mode shows thinking and writing on its single progress message from the start of work; that message is removed or marked finished when the run completes
- Show elapsed time for tool calls running longer than 20 seconds, updated on a timer so it keeps counting while a tool blocks without producing output; covers subagent cards and compact mode, and the total duration stays on the finished tool line. In compact mode, while several tools of one step are in flight, the progress line shows the still-running one (the most recently started if several), with that tool's timer — not a finished sibling. A finished subagent card keeps the time its whole run took. Durations use the same `· 🕒 1h 2m 3s` format as the assistant run footer
- A subagent card shows Task, Agent, and Model; when OpenCode sends a variant, the Model line is `provider/id (variant)`
- Render assistant replies with native Telegram formatting: real tables with the column alignment declared in markdown, bullet lists with their nesting, block quotes that keep their nested content, headings, and syntax-highlighted code. Numbered lists and checklists keep literal markers (`1.`, ✅/🔲), because Telegram clients number a native ordered list from zero and do not draw the native checkbox at all
- Deliver reasoning as a collapsed quote that expands on tap
Expand All @@ -72,7 +73,7 @@ No public inbound ports are required for normal usage.

### Session status in chat

- Keep a pinned status message in the chat
- Keep a pinned status message in the chat; it can be turned off in `/settings` (default on)
- Show session title, project, model, context usage, and changed files; when a variant is set, the model line is `provider/id (variant)`
- Auto-update status from SSE and tool events
- Preserve pinned message ID across bot restarts
Expand All @@ -82,6 +83,7 @@ No public inbound ports are required for normal usage.
- Whitelist by Telegram user ID (single-user mode)
- Ignore messages from non-authorized users
- Ignore updates queued while the bot was offline or unreachable, so they are not executed on startup
- If Telegram is unreachable at startup (network error, 5xx, 429), keep retrying with a growing delay capped at 60 seconds until it answers, then start polling; a rejected or invalid token (401/404) or any other fatal startup error logs the cause and exits the process with code 1 so a supervisor can restart it

### Configuration

Expand All @@ -94,7 +96,7 @@ No public inbound ports are required for normal usage.
- Configurable scheduled task limit (default: 10)
- Configurable bot locale
- Configurable visibility for thinking content and diff-file attachments
- Configurable compact output, assistant footer, message queue, and TTS modes (`/settings`)
- Configurable compact output, assistant footer, pinned session dashboard, message queue, and TTS modes (`/settings`)
- Configurable opt-in display of full thinking/reasoning content
- Configurable max code file size in KB (default: 100)
- Optional STT settings for voice transcription (`STT_API_URL`, `STT_API_KEY`, `STT_MODEL`, `STT_LANGUAGE`)
Expand Down Expand Up @@ -165,7 +167,7 @@ Agent picker behavior:
- [x] Background notifications for detached/non-current sessions in the currently selected project/worktree
- [x] Telegram-friendly result delivery, including sending generated code/files when needed
- [x] Interactive question and permission handling directly in chat (buttons + custom answers)
- [x] Live pinned session status in chat (project, model with variant in parentheses when set, context usage, changed files)
- [x] Live pinned session status in chat (project, model with variant in parentheses when set, context usage, changed files), with an opt-out in `/settings` that defaults to on
- [x] In-chat controls for model, agent, variant, and context
- [x] Built-in and custom command catalog access (`/commands`)
- [x] Trusted local JSON commands from the persistent application home, executed without OpenCode or model tokens
Expand Down Expand Up @@ -193,6 +195,7 @@ Agent picker behavior:
- [x] Optional message queue for text, voice, photos, rich formatted messages with photos, documents, and media groups sent while the agent is busy, managed from the bottom keyboard
- [x] Native Telegram rich message formatting for assistant replies (Bot API 10.1)
- [x] Incoming Telegram rich formatted messages (Bot API 10.1): converted to Markdown, accepted anywhere text is accepted, with photos attached and unsupported message types answered explicitly
- [x] Startup either reaches Telegram polling or the process exits: transient Telegram failures are retried in-process; a bad token or other fatal startup error exits with code 1

## Current Task List

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Scheduled tasks support. Turns the bot into a lightweight OpenClaw alternative f

Platforms: macOS, Windows, Linux

Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es`), Français (`fr`), Italiano (`it`), 한국어 (`ko`), Português (Brasil) (`pt`), Русский (`ru`), 简体中文 (`zh`)
Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es`), Français (`fr`), Italiano (`it`), 한국어 (`ko`), Português (Brasil) (`pt`), Русский (`ru`), Türkçe (`tr`), 简体中文 (`zh`)

<p align="center">
<img src="assets/screencast.gif" width="45%" alt="OpenCode Telegram Bot screencast" />
Expand Down Expand Up @@ -196,7 +196,7 @@ For this to work, the console OpenCode instance must be started on the same port

### Localization

- Supported locales: `en`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `zh`
- Supported locales: `en`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `tr`, `zh`
- The setup wizard asks for language first
- You can change locale later with `BOT_LOCALE`

Expand Down Expand Up @@ -237,7 +237,7 @@ Configuration can be provided through process environment variables or an `.env`
| `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`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `zh`) | No | `en` |
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `tr`, `zh`) | No | `en` |
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
| `MESSAGES_LIST_LIMIT` | User messages per page in `/messages` | No | `10` |
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
Expand Down Expand Up @@ -279,10 +279,11 @@ Logs are written to `./logs` when running from sources and to the runtime config

Runtime preferences are changed from `/settings` and stored in `settings.json`:

- Compact output mode
- Compact output mode: the progress message appears as soon as the model starts thinking or writing
- Delete progress on finish: available while compact output mode is on, removes the progress message when the run completes
- Thinking content display
- Assistant run footer display
- Pin session dashboard
- Diff file attachments
- Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages
- Audio replies: `off`, `all`, or `auto` when TTS is configured
Expand All @@ -293,7 +294,7 @@ With the message queue enabled, text, transcribed voice, photos, rich formatted
You can seed the initial defaults for any of these settings without hard-coding them in your Docker image by setting `INITIAL_SETTINGS_PRESET` to a JSON object. Only keys not yet persisted in `settings.json` are affected — settings the user has already changed via `/settings` are left untouched:

```env
INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto"}
INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto","pinnedDashboardEnabled":true}
```

Settings are written atomically: the new content goes to a temporary file that then replaces `settings.json`, and the previous version is kept as `settings.json.bak`. A crash during a write can never leave a truncated file — the bot falls back to the backup on the next start. If both `settings.json` and `settings.json.bak` are unreadable, the bot refuses to start instead of overwriting them, and the error names the file so you can fix or remove it manually.
Expand Down
6 changes: 6 additions & 0 deletions docs/LOCALIZATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ Manual checks:

By default, adding a new locale dictionary and registering it does not require new tests if localization logic is unchanged.

Two existing tests keep a hand-written list of locales and must get the new one, otherwise they silently skip it:

- `tests/i18n/placeholders.test.ts` - placeholders match `en`
- `tests/i18n/callback-answer-length.test.ts` - callback answers fit Telegram's limit

Add or update tests only if you change locale resolution/normalization behavior (for example alias handling).

Relevant test files if needed:
Expand All @@ -123,5 +128,6 @@ Relevant test files if needed:
- [ ] Added locale entry to `LOCALE_DEFINITIONS` (`en` first, then alphabetical)
- [ ] Updated `README.md` (`Languages:` line, `Supported locales:` list, `BOT_LOCALE` table row)
- [ ] Updated `.env.example` (`# Supported locales:` comment)
- [ ] Added the locale to `tests/i18n/placeholders.test.ts` and `tests/i18n/callback-answer-length.test.ts`
- [ ] Ran `npm run build`, `npm run lint`, `npm run typecheck`, `npm test`
- [ ] Manually validated `BOT_LOCALE` and wizard language selection
12 changes: 12 additions & 0 deletions docs/release-notes/v0.25.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## Changes

- **Optional dashboard pinning** — the pinned session status message can now be turned off with the "Pin session dashboard" toggle in `/settings`.
- **Turkish localization** — the bot interface is now available in Turkish.

## Fixes

- **Compact progress at the start of work** — in compact mode the progress message now appears as soon as the model starts thinking or writing, instead of the chat staying silent until the first tool runs.
- **Compact progress timer with parallel operations** — when several operations run in one step, the compact progress line now keeps showing the still-running one with its timer instead of getting stuck on an already finished sibling.
- **Startup when Telegram is unreachable** — the bot now keeps retrying Telegram at startup until it answers, and exits with an error on an invalid token instead of hanging without polling.

Full changelog: https://github.com/grinev/opencode-telegram-bot/compare/v0.25.1...v0.25.2
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@grinev/opencode-telegram-bot",
"version": "0.25.1",
"version": "0.25.2",
"description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
"type": "module",
"main": "./dist/index.js",
Expand Down
134 changes: 131 additions & 3 deletions src/app/bootstrap/start-bot-app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { cleanupBotRuntime, createBot } from "../../bot/index.js";
import { cleanupBotRuntime, createBot, restoreFollowedSessionOnPollingStart } from "../../bot/index.js";
import { createScheduledTaskDeliverySender } from "../../bot/messages/scheduled-task-delivery.js";
import { config } from "../../config.js";
import { opencodeAutoRestartService } from "../../opencode/auto-restart.js";
Expand All @@ -19,10 +19,112 @@ import { clearServiceStateFile } from "../../runtime/service/manager.js";
import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../../runtime/service/env.js";
import { flushLogger, getLogFilePath, initializeLogger, logger } from "../../utils/logger.js";
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
import { getTelegramRetryAfterMs } from "../../utils/telegram-rate-limit-retry.js";

const SHUTDOWN_TIMEOUT_MS = 5000;
const SETTINGS_FLUSH_TIMEOUT_MS = 1000;
const LOG_FLUSH_TIMEOUT_MS = 1000;
const TELEGRAM_STARTUP_RETRY_BASE_MS = 1000;
const TELEGRAM_STARTUP_RETRY_CAP_MS = 60_000;
const TELEGRAM_FATAL_TOKEN_CODES = new Set([401, 404]);

function getTelegramErrorCode(error: unknown): number | null {
if (typeof error !== "object" || error === null) {
return null;
}

const errorCode = Reflect.get(error, "error_code");
if (typeof errorCode === "number" && Number.isFinite(errorCode)) {
return errorCode;
}

return null;
}

function isFatalTelegramTokenError(error: unknown): boolean {
const errorCode = getTelegramErrorCode(error);
return errorCode !== null && TELEGRAM_FATAL_TOKEN_CODES.has(errorCode);
}

function isTelegramStartupServerError(error: unknown): boolean {
const errorCode = getTelegramErrorCode(error);
return errorCode !== null && errorCode >= 500 && errorCode < 600;
}

function isRetryableTelegramStartupError(error: unknown): boolean {
if (isTelegramStartupServerError(error) || getTelegramRetryAfterMs(error) !== null) {
return true;
}

if (typeof error === "object" && error !== null && Reflect.get(error, "name") === "HttpError") {
return true;
}

if (error instanceof Error && /Network request for '.+' failed/i.test(error.message)) {
return true;
}

return false;
}

function nextTelegramStartupRetryDelayMs(error: unknown, attempt: number): number {
const exponentialMs = Math.min(
TELEGRAM_STARTUP_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1),
TELEGRAM_STARTUP_RETRY_CAP_MS,
);
const fromErrorMs = getTelegramRetryAfterMs(error, TELEGRAM_STARTUP_RETRY_BASE_MS, attempt - 1);
if (fromErrorMs === null) {
return exponentialMs;
}

return Math.min(Math.max(exponentialMs, fromErrorMs), TELEGRAM_STARTUP_RETRY_CAP_MS);
}

function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

async function callTelegramAtStartup<T>(
label: string,
operation: () => Promise<T>,
isShutdown: () => boolean,
): Promise<T | "shutdown"> {
let attempt = 0;

while (true) {
if (isShutdown()) {
return "shutdown";
}

try {
return await operation();
} catch (error) {
if (isShutdown()) {
return "shutdown";
}

if (isFatalTelegramTokenError(error)) {
logger.error("[App] Telegram rejected the bot token; not retrying", error);
throw error;
}

if (!isRetryableTelegramStartupError(error)) {
logger.error("[App] Telegram startup failed; not retrying", error);
throw error;
}

attempt += 1;
const delayMs = nextTelegramStartupRetryDelayMs(error, attempt);
logger.warn(
`[App] Telegram ${label} failed (attempt ${attempt}); retrying in ${delayMs}ms`,
error,
);
await wait(delayMs);
}
}
}

export async function startBotApp(): Promise<void> {
await initializeLogger();
Expand Down Expand Up @@ -159,7 +261,16 @@ export async function startBotApp(): Promise<void> {
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);

const webhookInfo = await bot.api.getWebhookInfo();
const isShutdown = (): boolean => shutdownStarted;
const webhookInfo = await callTelegramAtStartup(
"getWebhookInfo",
() => bot.api.getWebhookInfo(),
isShutdown,
);
if (webhookInfo === "shutdown") {
return;
}

if (webhookInfo.pending_update_count > 0) {
// Approximate: more updates can arrive before long polling actually drops
// the queue, and Telegram does not report how many were discarded.
Expand All @@ -169,17 +280,34 @@ export async function startBotApp(): Promise<void> {
}
if (webhookInfo.url) {
logger.info(`[Bot] Webhook detected: ${webhookInfo.url}, removing...`);
await bot.api.deleteWebhook();
const deleted = await callTelegramAtStartup("deleteWebhook", () => bot.api.deleteWebhook(), isShutdown);
if (deleted === "shutdown") {
return;
}
logger.info("[Bot] Webhook removed, switching to long polling");
}

const identity = await callTelegramAtStartup("getMe", () => bot.api.getMe(), isShutdown);
if (identity === "shutdown" || shutdownStarted) {
return;
}
bot.botInfo = identity;

try {
await bot.start({
drop_pending_updates: true,
onStart: (botInfo) => {
logger.info(`Bot @${botInfo.username} started!`);
restoreFollowedSessionOnPollingStart(bot);
},
});
} catch (error) {
if (isFatalTelegramTokenError(error)) {
logger.error("[App] Telegram rejected the bot token; not retrying", error);
} else {
logger.error("[App] Telegram startup failed; not retrying", error);
}
throw error;
} finally {
process.off("unhandledRejection", unhandledRejectionHandler);
process.off("uncaughtException", uncaughtExceptionHandler);
Expand Down
Loading
Loading