opencode-telegram-bot 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  A Telegram bot that forwards messages to an [OpenCode](https://opencode.ai) agent and returns the responses. Each chat gets a persistent session, so the agent remembers conversation context across messages.
4
4
 
5
+ ## đŸŽĨ Demo
6
+
7
+ https://github.com/user-attachments/assets/e071f536-7036-4e9c-a5fb-c77414626825
8
+
5
9
  ## Prerequisites
6
10
 
7
11
  - [Node.js](https://nodejs.org/) 18+
@@ -111,12 +115,16 @@ These are handled directly by the Telegram bot:
111
115
  | `/usage` | Show token and cost usage for this session |
112
116
  | `/help` | Show available commands |
113
117
 
118
+ The bot also registers these commands in Telegram's command menu (the `/` button), plus any OpenCode server commands discovered at startup (excluding hidden ones like `/init` and `/review`).
119
+
114
120
  ### Verbose Mode
115
121
 
116
122
  By default, the bot only shows the assistant's final text response. Use `/verbose` to toggle verbose mode (or `/verbose on|off` to set it explicitly), which also displays:
117
123
 
118
124
  - **Thinking/reasoning** -- shown as plain text with a 🧠 prefix, truncated to 500 characters
119
- - **Tool calls** -- shown as a compact one-line summary (e.g. `> read -- src/app.ts`)
125
+ - **Tool calls** -- shown as a compact one-line summary (e.g. `âš™ī¸ read -- src/app.ts`)
126
+ - **Delegations** -- shown when work is delegated to a subagent (e.g. `🧩 Delegated: Find relevant docs`)
127
+ - **Subagent details** -- reasoning/tool calls from subagents when available, plus a short `â„šī¸ Subagent responded` notice
120
128
 
121
129
  Verbose mode is per-chat and persists across bot restarts. Use `/verbose` again to turn it off.
122
130
 
@@ -128,6 +136,11 @@ Example with verbose mode on:
128
136
  âš™ī¸ grep -- pattern: "authenticate" in src/
129
137
  âš™ī¸ read -- src/auth/handler.ts
130
138
 
139
+ 🧩 Delegated: Search auth docs
140
+ 🧠 Thinking (agent: subagent): Scanning docs for auth references...
141
+ âš™ī¸ read -- README.md (agent: subagent)
142
+ â„šī¸ Subagent responded
143
+
131
144
  Here's what I found in the auth module...
132
145
  ```
133
146
 
@@ -217,17 +230,14 @@ Bot: Your sessions:
217
230
  Current session: Fix the broken tests
218
231
  Tap a session to switch or delete.
219
232
 
220
- You: /switch def6
221
- Bot: Switched to session: Auth refactoring (def67890)
222
-
223
233
  You: What were we working on?
224
234
  Bot: [agent responds with context from the auth refactoring session]
225
235
 
226
- You: /delete abc1
227
- Bot: Deleted session: Fix the broken tests (abc12345)
236
+ You: [tap "Auth refactoring"]
237
+ Bot: Switched to session: Auth refactoring
228
238
  ```
229
239
 
230
- The `/sessions` list only shows sessions created by the Telegram bot, not sessions from other OpenCode clients (like the TUI). You cannot delete the currently active session -- use `/new` or `/switch` first.
240
+ The `/sessions` list only shows sessions created by the Telegram bot, not sessions from other OpenCode clients (like the TUI). You cannot delete the currently active session -- use `/new` first.
231
241
 
232
242
  ## Session Persistence
233
243
 
package/dist/app.d.ts CHANGED
@@ -49,6 +49,10 @@ interface TelegramBot {
49
49
  getFileLink: (fileId: string) => Promise<{
50
50
  toString(): string;
51
51
  }>;
52
+ setMyCommands: (commands: Array<{
53
+ command: string;
54
+ description: string;
55
+ }>) => Promise<unknown>;
52
56
  };
53
57
  }
54
58
  export declare function startTelegram(options: StartOptions): Promise<TelegramBot>;
package/dist/index.js CHANGED
@@ -18589,6 +18589,9 @@ async function startTelegram(options) {
18589
18589
  const client = options.client || client_createOpencodeClient({ baseUrl: url });
18590
18590
  // Verify connection to the OpenCode server and fetch available commands
18591
18591
  const opencodeCommands = new Set();
18592
+ const opencodeCommandMenu = [];
18593
+ const hiddenOpenCodeCommands = new Set(["init", "review", "reviews"]);
18594
+ const isHiddenOpenCodeCommand = (name) => hiddenOpenCodeCommands.has(name.toLowerCase());
18592
18595
  let projectDirectory = "";
18593
18596
  try {
18594
18597
  const sessions = await client.session.list();
@@ -18606,7 +18609,16 @@ async function startTelegram(options) {
18606
18609
  const cmds = await client.command.list();
18607
18610
  if (cmds.data) {
18608
18611
  for (const cmd of cmds.data) {
18609
- opencodeCommands.add(cmd.name);
18612
+ const name = cmd.name;
18613
+ if (!name)
18614
+ continue;
18615
+ opencodeCommands.add(name);
18616
+ if (!isHiddenOpenCodeCommand(name)) {
18617
+ opencodeCommandMenu.push({
18618
+ command: name,
18619
+ description: cmd.description || "OpenCode command",
18620
+ });
18621
+ }
18610
18622
  }
18611
18623
  console.log(`[Telegram] Available OpenCode commands: ${[...opencodeCommands].join(", ")}`);
18612
18624
  }
@@ -18618,6 +18630,17 @@ async function startTelegram(options) {
18618
18630
  const telegramCommands = new Set([
18619
18631
  "start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose", "model", "usage",
18620
18632
  ]);
18633
+ const telegramCommandMenu = [
18634
+ { command: "new", description: "Start a new conversation" },
18635
+ { command: "sessions", description: "List your sessions" },
18636
+ { command: "title", description: "Rename a session (/title <text>)" },
18637
+ { command: "export", description: "Export session (/export full for details)" },
18638
+ { command: "verbose", description: "Toggle verbose mode" },
18639
+ { command: "model", description: "Search models (/model <keyword>)" },
18640
+ { command: "usage", description: "Show token and cost usage" },
18641
+ { command: "help", description: "Show available commands" },
18642
+ ];
18643
+ const getVisibleOpenCodeCommands = () => [...opencodeCommands].filter((command) => !isHiddenOpenCodeCommand(command));
18621
18644
  // Map of chatId -> sessionId for the active session per chat
18622
18645
  const chatSessions = new Map();
18623
18646
  // Set of all session IDs ever created/used by this bot (for filtering)
@@ -18951,6 +18974,63 @@ async function startTelegram(options) {
18951
18974
  let processingMsgDeleted = false;
18952
18975
  const sentToolIds = new Set();
18953
18976
  const sentReasoningIds = new Set();
18977
+ const sentSubtaskIds = new Set();
18978
+ let sawDelegation = false;
18979
+ let sentSubagentDetails = false;
18980
+ let sentSubagentResponseNotice = false;
18981
+ function extractTaskId(outputText) {
18982
+ const match = outputText.match(/task[_-]?id:\s*([\w-]+)/i);
18983
+ return match ? match[1] : null;
18984
+ }
18985
+ async function emitSubagentMessages(taskSessionId) {
18986
+ try {
18987
+ const messagesResult = await client.session.messages({
18988
+ path: { id: taskSessionId },
18989
+ });
18990
+ if (messagesResult.error || !messagesResult.data) {
18991
+ console.warn(`[Telegram] Failed to fetch subagent messages for ${taskSessionId}: ${JSON.stringify(messagesResult.error)}`);
18992
+ return;
18993
+ }
18994
+ for (const msg of messagesResult.data) {
18995
+ const info = msg.info;
18996
+ if (info?.role !== "assistant")
18997
+ continue;
18998
+ const parts = msg.parts || [];
18999
+ for (const part of parts) {
19000
+ if (part.type === "reasoning") {
19001
+ const text = part.text;
19002
+ if (!text)
19003
+ continue;
19004
+ const truncated = truncate(text.replace(/\n/g, " "), 500);
19005
+ await sendTelegramMessage(chatId, `\u{1F9E0} Thinking (agent: subagent): ${truncated}`, true);
19006
+ sentSubagentDetails = true;
19007
+ }
19008
+ if (part.type === "tool") {
19009
+ const tool = part.tool;
19010
+ const state = part.state;
19011
+ if (!state)
19012
+ continue;
19013
+ if (state.status !== "completed" && state.status !== "error")
19014
+ continue;
19015
+ const summary = summarizeTool(tool, state.input);
19016
+ let line = `\u{2699}\u{FE0F} ${summary} (agent: subagent)`;
19017
+ if (state.status === "error") {
19018
+ line += ` \u{274C}`;
19019
+ }
19020
+ await sendTelegramMessage(chatId, line, true);
19021
+ sentSubagentDetails = true;
19022
+ }
19023
+ }
19024
+ }
19025
+ if (!sentSubagentResponseNotice) {
19026
+ await sendTelegramMessage(chatId, "â„šī¸ Subagent responded", true);
19027
+ sentSubagentResponseNotice = true;
19028
+ }
19029
+ }
19030
+ catch (err) {
19031
+ console.warn("[Telegram] Error fetching subagent messages:", err);
19032
+ }
19033
+ }
18954
19034
  // Buffer the latest reasoning text -- we send it when a non-reasoning part arrives
18955
19035
  // so we get the complete thinking block rather than a partial one
18956
19036
  let pendingReasoning = null;
@@ -18976,7 +19056,10 @@ async function startTelegram(options) {
18976
19056
  sentReasoningIds.add(pendingReasoning.id);
18977
19057
  await deleteProcessingMsg();
18978
19058
  const truncated = truncate(pendingReasoning.text.replace(/\n/g, " "), 500);
18979
- await sendTelegramMessage(chatId, `\u{1F9E0} Thinking: ${truncated}`, true);
19059
+ const agentLabel = pendingReasoning.agent
19060
+ ? ` (agent: ${pendingReasoning.agent})`
19061
+ : "";
19062
+ await sendTelegramMessage(chatId, `\u{1F9E0} Thinking${agentLabel}: ${truncated}`, true);
18980
19063
  }
18981
19064
  pendingReasoning = null;
18982
19065
  }
@@ -18985,14 +19068,26 @@ async function startTelegram(options) {
18985
19068
  const ev = event;
18986
19069
  if (ev.type === "message.part.updated" && ev.properties) {
18987
19070
  const part = ev.properties.part;
18988
- // Only process events for our session
18989
- if (part.sessionID !== sessionId)
19071
+ const partSessionId = part.sessionID;
19072
+ const parentSessionId = part.parentSessionID ||
19073
+ part.parentSessionId;
19074
+ const rootSessionId = part.rootSessionID ||
19075
+ part.rootSessionId;
19076
+ const isPrimarySession = partSessionId === sessionId;
19077
+ const isChildSession = parentSessionId === sessionId || rootSessionId === sessionId;
19078
+ // Only process events for our session (or subagent sessions that link back)
19079
+ if (!isPrimarySession && !isChildSession)
18990
19080
  continue;
19081
+ if (!isPrimarySession && isChildSession && verbose) {
19082
+ // Subagent events are allowed to stream in verbose mode
19083
+ }
18991
19084
  const partText = part.text;
18992
19085
  if (part.type === "reasoning" && part.id) {
18993
19086
  // Buffer reasoning -- keep updating with the latest full text
18994
19087
  if (partText) {
18995
- pendingReasoning = { id: part.id, text: partText };
19088
+ const agent = part.agent ||
19089
+ (!isPrimarySession ? "subagent" : undefined);
19090
+ pendingReasoning = { id: part.id, text: partText, agent };
18996
19091
  }
18997
19092
  }
18998
19093
  else if (part.type === "tool" && part.id) {
@@ -19007,12 +19102,74 @@ async function startTelegram(options) {
19007
19102
  sentToolIds.add(part.id);
19008
19103
  await deleteProcessingMsg();
19009
19104
  const tool = part.tool;
19010
- const summary = summarizeTool(tool, state.input);
19011
- let line = `\u{2699}\u{FE0F} ${summary}`;
19012
- if (state.status === "error") {
19013
- line += ` \u{274C}`;
19105
+ const agent = part.agent ||
19106
+ (!isPrimarySession ? "subagent" : undefined);
19107
+ if (tool === "task") {
19108
+ const input = state.input;
19109
+ const description = input?.description || input?.prompt || summarizeTool(tool, state.input);
19110
+ let line = `\u{1F9E9} Delegated`;
19111
+ if (description) {
19112
+ line += `: ${truncate(description, 120)}`;
19113
+ }
19114
+ if (agent) {
19115
+ line += ` (agent: ${agent})`;
19116
+ }
19117
+ if (state.status === "error") {
19118
+ line += ` \u{274C}`;
19119
+ }
19120
+ await sendTelegramMessage(chatId, line, true);
19121
+ sawDelegation = true;
19122
+ if (state.output && verbose) {
19123
+ const rawOutput = typeof state.output === "string"
19124
+ ? state.output
19125
+ : JSON.stringify(state.output, null, 2);
19126
+ const outputText = rawOutput.trim();
19127
+ if (outputText) {
19128
+ const taskSessionId = extractTaskId(outputText);
19129
+ if (taskSessionId) {
19130
+ await emitSubagentMessages(taskSessionId);
19131
+ }
19132
+ if (!sentSubagentDetails && !sentSubagentResponseNotice) {
19133
+ await sendTelegramMessage(chatId, "â„šī¸ Subagent responded (no thought/tool details available)", true);
19134
+ sentSubagentResponseNotice = true;
19135
+ }
19136
+ }
19137
+ }
19138
+ }
19139
+ else {
19140
+ const summary = summarizeTool(tool, state.input);
19141
+ let line = `\u{2699}\u{FE0F} ${summary}`;
19142
+ if (agent) {
19143
+ line += ` (agent: ${agent})`;
19144
+ }
19145
+ if (state.status === "error") {
19146
+ line += ` \u{274C}`;
19147
+ }
19148
+ await sendTelegramMessage(chatId, line, true);
19149
+ }
19150
+ }
19151
+ }
19152
+ }
19153
+ else if (part.type === "subtask") {
19154
+ await flushReasoning();
19155
+ if (verbose) {
19156
+ const id = part.id;
19157
+ if (!id || !sentSubtaskIds.has(id)) {
19158
+ if (id)
19159
+ sentSubtaskIds.add(id);
19160
+ await deleteProcessingMsg();
19161
+ const description = part.description;
19162
+ const agent = part.agent ||
19163
+ (!isPrimarySession ? "subagent" : undefined);
19164
+ let line = "\u{1F9E9} Delegated";
19165
+ if (description) {
19166
+ line += `: ${truncate(description, 120)}`;
19167
+ }
19168
+ if (agent) {
19169
+ line += ` (agent: ${agent})`;
19014
19170
  }
19015
19171
  await sendTelegramMessage(chatId, line, true);
19172
+ sawDelegation = true;
19016
19173
  }
19017
19174
  }
19018
19175
  }
@@ -19053,6 +19210,35 @@ async function startTelegram(options) {
19053
19210
  if (!finalText) {
19054
19211
  finalText = "The agent returned an empty response.";
19055
19212
  }
19213
+ if (verbose && sawDelegation) {
19214
+ const lines = finalText.split("\n");
19215
+ let inTaskResult = false;
19216
+ const filtered = lines.filter((line) => {
19217
+ const trimmed = line.trim();
19218
+ if (/^<task_result>$/i.test(trimmed)) {
19219
+ inTaskResult = true;
19220
+ return false;
19221
+ }
19222
+ if (/^<\/task_result>$/i.test(trimmed)) {
19223
+ inTaskResult = false;
19224
+ return false;
19225
+ }
19226
+ if (inTaskResult)
19227
+ return false;
19228
+ if (/^Subagent response:/i.test(trimmed))
19229
+ return false;
19230
+ if (/^Subagent (ran|returned|reported)/i.test(trimmed))
19231
+ return false;
19232
+ if (/^Ran the subagent/i.test(trimmed))
19233
+ return false;
19234
+ return true;
19235
+ });
19236
+ const cleaned = filtered.join("\n").trim();
19237
+ if (!cleaned) {
19238
+ return;
19239
+ }
19240
+ finalText = cleaned;
19241
+ }
19056
19242
  const chunks = splitMessage(finalText, 4096);
19057
19243
  for (const chunk of chunks) {
19058
19244
  await sendTelegramMessage(chatId, chunk);
@@ -19119,6 +19305,25 @@ async function startTelegram(options) {
19119
19305
  const bot = options.botFactory
19120
19306
  ? options.botFactory(token)
19121
19307
  : new lib.Telegraf(token);
19308
+ async function registerCommandMenu() {
19309
+ const combined = [...telegramCommandMenu, ...opencodeCommandMenu];
19310
+ const seen = new Set();
19311
+ const commands = [];
19312
+ for (const entry of combined) {
19313
+ if (!entry.command || seen.has(entry.command))
19314
+ continue;
19315
+ seen.add(entry.command);
19316
+ commands.push(entry);
19317
+ }
19318
+ if (commands.length === 0)
19319
+ return;
19320
+ try {
19321
+ await bot.telegram.setMyCommands(commands);
19322
+ }
19323
+ catch (err) {
19324
+ console.warn("[Telegram] Failed to register command menu:", err);
19325
+ }
19326
+ }
19122
19327
  // Middleware to check if the user is authorized
19123
19328
  bot.use((ctx, next) => {
19124
19329
  if (!authorizedUserId) {
@@ -19134,24 +19339,26 @@ async function startTelegram(options) {
19134
19339
  return;
19135
19340
  }
19136
19341
  });
19342
+ await registerCommandMenu();
19137
19343
  // Handle /start command
19138
19344
  bot.start((ctx) => {
19139
19345
  let msg = "Hello! I'm your OpenCode bot. Send me a message and I'll forward it to the OpenCode agent.\n\n" +
19140
19346
  "Bot commands:\n" +
19141
19347
  "/new - Start a new conversation\n" +
19142
- "/sessions - List your sessions\n" +
19348
+ "/sessions - List your sessions (buttons)\n" +
19143
19349
  "/title <text> - Rename the current session\n" +
19144
19350
  "/export - Export the current session as a markdown file\n" +
19145
19351
  "/export full - Export with all details (thinking, costs, steps)\n" +
19146
19352
  "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
19147
19353
  "/verbose on|off - Set verbose mode explicitly\n" +
19148
- "/model - Show or search available models\n" +
19354
+ "/model <keyword> - Search available models\n" +
19149
19355
  "/usage - Show token and cost usage for this session\n" +
19150
19356
  "/help - Show this help message\n";
19151
- if (opencodeCommands.size > 0) {
19357
+ const visibleOpenCodeCommands = getVisibleOpenCodeCommands();
19358
+ if (visibleOpenCodeCommands.length > 0) {
19152
19359
  msg +=
19153
19360
  "\nOpenCode commands are also available:\n" +
19154
- [...opencodeCommands].map((c) => `/${c}`).join(", ");
19361
+ visibleOpenCodeCommands.map((c) => `/${c}`).join(", ");
19155
19362
  }
19156
19363
  ctx.reply(msg);
19157
19364
  });
@@ -19160,19 +19367,20 @@ async function startTelegram(options) {
19160
19367
  let msg = "Send me any message and I'll process it using OpenCode.\n\n" +
19161
19368
  "Bot commands:\n" +
19162
19369
  "/new - Start a new conversation\n" +
19163
- "/sessions - List your sessions\n" +
19370
+ "/sessions - List your sessions (buttons)\n" +
19164
19371
  "/title <text> - Rename the current session\n" +
19165
19372
  "/export - Export the current session as a markdown file\n" +
19166
19373
  "/export full - Export with all details (thinking, costs, steps)\n" +
19167
19374
  "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
19168
19375
  "/verbose on|off - Set verbose mode explicitly\n" +
19169
- "/model - Show or search available models\n" +
19376
+ "/model <keyword> - Search available models\n" +
19170
19377
  "/usage - Show token and cost usage for this session\n" +
19171
19378
  "/help - Show this help message\n";
19172
- if (opencodeCommands.size > 0) {
19379
+ const visibleOpenCodeCommands = getVisibleOpenCodeCommands();
19380
+ if (visibleOpenCodeCommands.length > 0) {
19173
19381
  msg +=
19174
19382
  "\nOpenCode commands:\n" +
19175
- [...opencodeCommands].map((c) => `/${c}`).join(", ");
19383
+ visibleOpenCodeCommands.map((c) => `/${c}`).join(", ");
19176
19384
  }
19177
19385
  ctx.reply(msg);
19178
19386
  });
@@ -19842,7 +20050,9 @@ async function startTelegram(options) {
19842
20050
  }
19843
20051
  // Check if it's a known OpenCode command
19844
20052
  if (!opencodeCommands.has(commandName)) {
19845
- const available = [...opencodeCommands].map((c) => `/${c}`).join(", ");
20053
+ const available = getVisibleOpenCodeCommands()
20054
+ .map((c) => `/${c}`)
20055
+ .join(", ");
19846
20056
  await ctx.reply(`Unknown command: /${commandName}\n\n` +
19847
20057
  `Available OpenCode commands: ${available || "none"}\n` +
19848
20058
  `Bot commands: /new, /help`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-bot",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "type": "module",
5
5
  "description": "Telegram bot that forwards messages to an OpenCode agent",
6
6
  "main": "./dist/index.js",