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
13 changes: 8 additions & 5 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,29 @@ No public inbound ports are required for normal usage.
- Browse up to `SESSIONS_LIST_LIMIT` recent root sessions across projects and git worktrees with running, idle, question and permission status; select one to switch project and follow it, including after detach
- Switching to an existing session adopts the agent, model, and variant it last ran with
- Create a new session
- Use OpenCode-generated session title (based on conversation)
- Use OpenCode-generated session title (based on conversation); a session OpenCode has not named yet is shown as "new session" wherever the bot names a session, and `/status`, `/rename` and `/detach` name the current session with the title OpenCode has for it at that moment

### Task handling

- Send text prompts to OpenCode
- Accept voice/audio messages, transcribe via Whisper-compatible STT API, and forward recognized text as prompts
- Interrupt current task (ESC equivalent)
- Optionally accept text, transcribed voice, photos, rich formatted messages with photos, supported documents, and media groups sent while a task is running, at most `MAX_QUEUED_PROMPTS` (5) waiting at a time: on OpenCode V2 they wait in the session inbox and are steered into the running turn (Steer, the V2 default) or start their own run after it (Queue); on V1 the bot holds them, with at most 20 MiB of raw Telegram media bytes checked from reliable `file_size` before downloads; the V1 On/Off choice and the V2 mode are kept separately, so switching versions changes neither
- Handle OpenCode questions with inline options and custom text answers
- Handle OpenCode questions with inline options and custom text answers; the custom answer button is offered only when the question accepts a custom answer
- In a multi-select question the custom text becomes one more tickable row next to the options, and Done sends it together with the ticked options
- Send selected/custom answers back to OpenCode (`question.reply`)
- Send selected/custom answers back to OpenCode (`question.reply`); on V2 a tapped choice is sent as the value OpenCode expects, while the buttons and the summary show its label
- Handle permission requests interactively (`allow once` / `always` / `reject`)

### Result delivery

- Send each completed assistant response after completion signal from SSE
- When the assistant footer is on, every answered turn of the followed session ends with its own footer — including a turn OpenCode starts by itself after a background command or subagent ends (V2) and a prompt typed in an attached OpenCode TUI or Desktop — with that turn's agent and model and the time from its own start; a turn that is aborted, errors, or is stopped from an attached client gets none
- In draft streaming mode, assistant text written before a question or permission prompt is sent as a message above that prompt when it appears, and is not sent again when the reply completes
- If that send fails, the reply is not sent again; when Telegram accepts sends again, the chat gets a notice that the last assistant reply was not delivered
- In edit streaming mode the chat reads in the order things happened: a reply the agent wrote before its next tool call, thinking, subagent, document, question or permission prompt sits above it, and the next tool opens a new message (in compact mode, a new progress message) below the reply
- After a mid-session Telegram outage, the next new message is answered without restarting the app
- Compact output mode shows thinking and writing on one progress message per stretch of work between replies and prompts, from the start of that stretch; the message is removed or marked finished when the reply or prompt lands, or when the run ends if nothing followed
- In full mode, show every foreground tool operation when it starts, without a timer until 20 seconds; edit its line in place as it runs and finishes, keeping parallel operations in start order. A finished call lasting at least 20 seconds shows its total duration. Subagent (`task`) operations use their cards instead of tool lines; a tool delivered as a document loses its running text line when the document arrives
- Compact output mode shows thinking and writing on one progress message per stretch of work between replies and prompts, from the start of that stretch; the message is removed or marked finished when the reply or prompt lands, or when the run ends if nothing followed. A message whose stretch started a background operation (OpenCode V2) stays working on that operation with its timer past the reply, prompt or end of the run, and is removed or marked finished with its own counts when its last background operation ends
- In full mode, show every foreground tool operation when it starts, without a timer until 20 seconds; edit its line in place as it runs and finishes, keeping parallel operations in start order. A finished call lasting at least 20 seconds shows its total duration. Subagent (`task`) operations use their cards instead of tool lines; a tool delivered as a document loses its running text line when the document arrives. A background command or subagent (OpenCode V2) keeps its running line or card, with its timer, after the turn ends and gets its finished line or `✅ Completed` with the total duration when the operation itself ends; after `/abort`, a session switch or a lost event stream it stays as it was
- 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
Expand Down Expand Up @@ -135,6 +137,7 @@ Current command set:
- `/skills` - browse and run OpenCode skills
- `/opencode_start` - start local OpenCode server
- `/opencode_stop` - stop local OpenCode server; available during an active request and kills the local process even if health is hung
- `/reload` - V2 only: reload the OpenCode configuration (config, plugins, providers and models, agents, commands, skills, MCP) for every loaded project without restarting the server; available during an active request, blocked while an interaction is on screen; the model menu reflects the reloaded providers at once
- `/help` - show command help
- `/ls` - interactive file browser for the current project directory; a text file can be attached to the next prompt from its detail view

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ opencode-telegram config
| `/tasklist` | Browse and delete scheduled tasks |
| `/opencode_start` | Start the local OpenCode server on the bot machine |
| `/opencode_stop` | Stop the local OpenCode server, including during a run |
| `/reload` | Reload the OpenCode configuration without restarting the server (V2 only) |
| `/help` | Show available commands |

Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active. Voice/audio messages are transcribed and then sent as prompts when STT is configured.
Expand Down
15 changes: 15 additions & 0 deletions docs/release-notes/v0.26.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Changes
- **Reload the OpenCode configuration** — on OpenCode V2, `/reload` makes the server pick up changed config, providers, models, agents, MCP servers and skills without a restart, and the model menu shows the result at once.

## Fixes
- **Plugin provider models survive a server start** — after `/opencode_start` or auto-restart, models from a plugin provider return to the model menu as soon as the server lists them, and a selected plugin model is no longer swapped for the config default.
- **Files accepted while an idle project wakes up** — on OpenCode V2, a photo, PDF or album sent to a model that supports it is no longer refused with "Current model doesn't support image input" after the project sat idle, and the model menu and pinned dashboard no longer come up empty at that moment.
- **Choice answers accepted on V2** — tapping a choice or `true`/`false` in an agent question, such as the web search provider prompt, now reaches the agent as that value instead of failing with "Failed to send answers to agent", and questions that allow no custom answer no longer offer `🔤 Custom answer`.
- **Background commands and subagents keep their timer** — on OpenCode V2, a command or subagent the agent sends to the background now stays running with its 🕒 timer until it actually ends and then shows its total duration, instead of freezing when the agent's turn ends.
- **Footer for turns the bot did not start** — OpenCode's own follow-up turn after a background command or subagent, and a prompt typed in an attached OpenCode TUI or Desktop, now end with the agent, model and duration footer too.
- **Replies stay in order** — with the default `edit` response streaming, a reply the agent wrote before its next command now appears above that command instead of below it.
- **Session name in `/status` and `/new`** — a session OpenCode has not titled yet is now shown as "new session" instead of a blank in `/status`, the `/new` reply and the session lists, and `/status` picks up the generated title once OpenCode names the session.
- **`/recent` with a deleted project folder** — `/recent` now shows the session list instead of a server error when one of the recent sessions belongs to a folder that no longer exists.
- **No false unauthorized warnings** — pinning the session dashboard no longer writes an "Unauthorized access attempt" warning with the bot's own ID to the log.

Full changelog: https://github.com/grinev/opencode-telegram-bot/compare/v0.26.0...v0.26.1
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.26.0",
"version": "0.26.1",
"description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
"type": "module",
"main": "./dist/index.js",
Expand Down
4 changes: 4 additions & 0 deletions src/app/bootstrap/app-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface AppContainer {
resetAggregator(): void;
/** Clears response streams, tool trackers, background tracking and run state. */
resetRuntimeStreams(reason: string): void;
/** Stops following background operations; their lines and cards stay as they are. */
stopBackgroundOperations(reason: string, sessionId?: string): void;
/**
* Stops ready-restore, the model catalog wait, event listening and the heartbeat,
* and clears runtime state.
Expand Down Expand Up @@ -134,6 +136,8 @@ export function createAppContainer(): AppContainer {
resetInteractionError: (scope, reason) => interactionManager.clearErrorScope(scope, reason),
resetAggregator: () => summaryAggregator.clear(),
resetRuntimeStreams: (reason) => eventSubscriptionService.clearRuntimeState(reason),
stopBackgroundOperations: (reason, sessionId) =>
eventSubscriptionService.stopBackgroundOperations(reason, sessionId),

cleanupProcess: (reason) => {
stopReadyRestore();
Expand Down
9 changes: 9 additions & 0 deletions src/app/formatters/session-title-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { t } from "../../i18n/index.js";

/**
* Name a session is shown under. OpenCode V2 leaves a new session untitled until
* it names it after the first prompt; until then it reads like the dashboard's.
*/
export function formatSessionTitle(title: string): string {
return title || t("pinned.default_session_title");
}
13 changes: 10 additions & 3 deletions src/app/formatters/tool-message-batcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@ import { logger } from "../../utils/logger.js";

type SendTextCallback = (sessionId: string, text: string) => Promise<void>;
type SendFileCallback = (sessionId: string, fileData: CodeFileData) => Promise<void>;
type TextGate = () => Promise<void>;

interface ToolMessageBatcherOptions {
sendText: SendTextCallback;
sendFile: SendFileCallback;
/** Asked when a text message is queued: what its send has to wait for, if anything. */
takeTextGate?: (sessionId: string) => TextGate | undefined;
}

export class ToolMessageBatcher {
private readonly sendText: SendTextCallback;
private readonly sendFile: SendFileCallback;
private readonly takeTextGate: ToolMessageBatcherOptions["takeTextGate"];
private readonly sessionTasks: Map<string, Promise<void>> = new Map();
private generation = 0;

constructor(options: ToolMessageBatcherOptions) {
this.sendText = options.sendText;
this.sendFile = options.sendFile;
this.takeTextGate = options.takeTextGate;
}

enqueue(sessionId: string, message: string): void {
Expand All @@ -31,10 +36,12 @@ export class ToolMessageBatcher {
}

const expectedGeneration = this.generation;
const gate = this.takeTextGate?.(sessionId);
logger.debug(`[ToolBatcher] Sending text message: session=${sessionId}, reason=${reason}`);
void this.enqueueTask(sessionId, () =>
this.sendTextSafe(sessionId, normalizedMessage, reason, expectedGeneration),
);
void this.enqueueTask(sessionId, async () => {
await gate?.();
await this.sendTextSafe(sessionId, normalizedMessage, reason, expectedGeneration);
});
}

enqueueUniqueByPrefix(sessionId: string, message: string, prefix: string): void {
Expand Down
23 changes: 23 additions & 0 deletions src/app/managers/assistant-run-state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface AssistantRunResolvedInfo {

export interface AssistantRunInfo extends AssistantRunStartInfo {
sessionId: string;
/** False for a turn the bot only observed: OpenCode's own follow-up or a prompt typed in a client. */
startedByBot: boolean;
actualAgent?: string | undefined;
actualProviderID?: string | undefined;
actualModelID?: string | undefined;
Expand All @@ -33,6 +35,7 @@ export class AssistantRunState {
resetStreamThrottle(sessionId);
this.runs.set(sessionId, {
sessionId,
startedByBot: true,
startedAt: info.startedAt,
configuredAgent: info.configuredAgent,
configuredProviderID: info.configuredProviderID,
Expand All @@ -45,6 +48,22 @@ export class AssistantRunState {
);
}

/** Opens a run for a turn the bot did not start; its agent and model come with its reply. */
startObservedRun(sessionId: string, startedAt: number): void {
if (!sessionId || this.runs.has(sessionId)) {
return;
}

this.runs.set(sessionId, {
sessionId,
startedByBot: false,
startedAt,
hasCompletedResponse: false,
});

logger.debug(`[AssistantRunState] Started observed run: session=${sessionId}`);
}

markResponseCompleted(sessionId: string, info?: AssistantRunResolvedInfo): void {
const run = this.runs.get(sessionId);
if (!run) {
Expand All @@ -67,6 +86,10 @@ export class AssistantRunState {
return this.runs.has(sessionId);
}

hasBotRun(sessionId: string): boolean {
return this.runs.get(sessionId)?.startedByBot === true;
}

isResponseCompleted(sessionId: string): boolean {
return this.runs.get(sessionId)?.hasCompletedResponse === true;
}
Expand Down
43 changes: 41 additions & 2 deletions src/app/managers/question-manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Question, QuestionState, QuestionAnswer } from "../types/question.js";
import type { Question, QuestionOption, QuestionState, QuestionAnswer } from "../types/question.js";
import type { InteractionManager } from "./interaction-manager.js";
import { logger } from "../../utils/logger.js";

function formatOptionLine(option: QuestionOption): string {
return `* ${option.label}: ${option.description}`;
}

export class QuestionManager {
constructor(private readonly interactionManager: InteractionManager) {}

Expand Down Expand Up @@ -105,7 +109,7 @@ export class QuestionManager {
const selected = state.selectedOptions.get(questionIndex) || new Set();
const options = Array.from(selected).flatMap((idx) => {
const opt = question.options[idx];
return opt ? [`* ${opt.label}: ${opt.description}`] : [];
return opt ? [formatOptionLine(opt)] : [];
});

return options.join("\n");
Expand Down Expand Up @@ -183,6 +187,41 @@ export class QuestionManager {
return items;
}

/**
* The answer items sent to OpenCode for one question. A choice that carries a value is
* sent as that value; one without is sent as its display line, like `getAnswerItems`.
*/
getReplyItems(questionIndex: number): string[] {
const question = this.state?.questions[questionIndex];
if (!question) {
return [];
}

const customAnswer = this.getCustomAnswer(questionIndex);
if (!question.multiple && customAnswer) {
return this.getAnswerItems(questionIndex);
}

const items = Array.from(this.getSelectedOptions(questionIndex)).flatMap((idx) => {
const opt = question.options[idx];
if (!opt) {
return [];
}
if (opt.value !== undefined) {
return [opt.value];
}
return formatOptionLine(opt)
.split("\n")
.filter((part) => part.trim());
});

if (question.multiple && customAnswer && this.isCustomAnswerSelected(questionIndex)) {
items.push(customAnswer);
}

return items;
}

nextQuestion(): void {
const state = this.state;
if (!state) {
Expand Down
Loading
Loading