opencode-telegram-bot 1.0.2 → 1.0.3

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 (3) hide show
  1. package/README.md +22 -0
  2. package/dist/index.js +233 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -105,8 +105,30 @@ These are handled directly by the Telegram bot:
105
105
  | `/delete <id>` | Delete a session from the server |
106
106
  | `/export` | Export the current session as a markdown file |
107
107
  | `/export full` | Export with all details (thinking, costs, steps) |
108
+ | `/verbose` | Toggle verbose mode (show thinking and tool calls in chat) |
109
+ | `/verbose on|off` | Explicitly enable/disable verbose mode |
108
110
  | `/help` | Show available commands |
109
111
 
112
+ ### Verbose Mode
113
+
114
+ 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:
115
+
116
+ - **Thinking/reasoning** -- shown as plain text with a 🧠 prefix, truncated to 500 characters
117
+ - **Tool calls** -- shown as a compact one-line summary (e.g. `> read -- src/app.ts`)
118
+
119
+ Verbose mode is per-chat and persists across bot restarts. Use `/verbose` again to turn it off.
120
+
121
+ Example with verbose mode on:
122
+
123
+ ```
124
+ 🧠 Thinking: Let me analyze the authentication flow and check for potential issues...
125
+
126
+ ⚙️ grep -- pattern: "authenticate" in src/
127
+ ⚙️ read -- src/auth/handler.ts
128
+
129
+ Here's what I found in the auth module...
130
+ ```
131
+
110
132
  ### Session Export
111
133
 
112
134
  The `/export` command builds a markdown file from the current session and saves it to the directory where OpenCode is running. The file is also sent back to you as a Telegram document.
package/dist/index.js CHANGED
@@ -18588,12 +18588,19 @@ async function startTelegram(options) {
18588
18588
  const client = client_createOpencodeClient({ baseUrl: url });
18589
18589
  // Verify connection to the OpenCode server and fetch available commands
18590
18590
  const opencodeCommands = new Set();
18591
+ let projectDirectory = "";
18591
18592
  try {
18592
18593
  const sessions = await client.session.list();
18593
18594
  if (sessions.error) {
18594
18595
  throw new Error(`Server returned error: ${JSON.stringify(sessions.error)}`);
18595
18596
  }
18596
18597
  console.log(`[Telegram] Connected to OpenCode server at ${url}`);
18598
+ // Fetch the project directory (needed for SSE event subscription)
18599
+ const pathResult = await client.path.get();
18600
+ if (pathResult.data?.directory) {
18601
+ projectDirectory = pathResult.data.directory;
18602
+ console.log(`[Telegram] Project directory: ${projectDirectory}`);
18603
+ }
18597
18604
  // Fetch available OpenCode commands
18598
18605
  const cmds = await client.command.list();
18599
18606
  if (cmds.data) {
@@ -18608,12 +18615,14 @@ async function startTelegram(options) {
18608
18615
  }
18609
18616
  // Telegram-only commands that should not be forwarded to OpenCode
18610
18617
  const telegramCommands = new Set([
18611
- "start", "help", "new", "sessions", "switch", "title", "delete", "export",
18618
+ "start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose",
18612
18619
  ]);
18613
18620
  // Map of chatId -> sessionId for the active session per chat
18614
18621
  const chatSessions = new Map();
18615
18622
  // Set of all session IDs ever created/used by this bot (for filtering)
18616
18623
  const knownSessionIds = new Set();
18624
+ // Set of chatIds with verbose mode enabled
18625
+ const chatVerboseMode = new Set();
18617
18626
  /**
18618
18627
  * Save the chat-to-session mapping and known session IDs to disk.
18619
18628
  */
@@ -18622,6 +18631,7 @@ async function startTelegram(options) {
18622
18631
  const data = {
18623
18632
  active: Object.fromEntries(chatSessions),
18624
18633
  known: [...knownSessionIds],
18634
+ verbose: [...chatVerboseMode],
18625
18635
  };
18626
18636
  (0,external_node_fs_.writeFileSync)(SESSIONS_FILE, JSON.stringify(data, null, 2));
18627
18637
  }
@@ -18637,14 +18647,16 @@ async function startTelegram(options) {
18637
18647
  // Load from file (supports both old flat format and new {active, known} format)
18638
18648
  let storedActive = {};
18639
18649
  let storedKnown = [];
18650
+ let storedVerbose = [];
18640
18651
  if ((0,external_node_fs_.existsSync)(SESSIONS_FILE)) {
18641
18652
  try {
18642
18653
  const raw = (0,external_node_fs_.readFileSync)(SESSIONS_FILE, "utf-8");
18643
18654
  const parsed = JSON.parse(raw);
18644
18655
  if (parsed.active && typeof parsed.active === "object") {
18645
- // New format: { active: {...}, known: [...] }
18656
+ // New format: { active: {...}, known: [...], verbose: [...] }
18646
18657
  storedActive = parsed.active;
18647
18658
  storedKnown = parsed.known || [];
18659
+ storedVerbose = parsed.verbose || [];
18648
18660
  }
18649
18661
  else {
18650
18662
  // Old format: flat { chatId: sessionId }
@@ -18656,6 +18668,13 @@ async function startTelegram(options) {
18656
18668
  console.warn("[Telegram] Failed to parse sessions file:", err);
18657
18669
  }
18658
18670
  }
18671
+ // Restore verbose mode preferences
18672
+ for (const chatId of storedVerbose) {
18673
+ chatVerboseMode.add(chatId);
18674
+ }
18675
+ if (storedVerbose.length > 0) {
18676
+ console.log(`[Telegram] Restored verbose mode for ${storedVerbose.length} chat(s)`);
18677
+ }
18659
18678
  // Fetch all server sessions once for validation and fallback matching
18660
18679
  let serverSessions = [];
18661
18680
  try {
@@ -18758,6 +18777,52 @@ async function startTelegram(options) {
18758
18777
  console.warn("[Telegram] Failed to auto-title session:", err);
18759
18778
  }
18760
18779
  }
18780
+ /**
18781
+ * Build a one-line summary for a tool call, picking the most meaningful input field.
18782
+ */
18783
+ function summarizeTool(tool, input) {
18784
+ if (!input)
18785
+ return tool;
18786
+ // Pick the most descriptive field based on common tool patterns
18787
+ const summaryField = input.filePath || input.path || input.command || input.pattern ||
18788
+ input.url || input.query || input.content || input.prompt ||
18789
+ input.description || input.name;
18790
+ if (summaryField && typeof summaryField === "string") {
18791
+ return `${tool} -- ${truncate(summaryField, 80)}`;
18792
+ }
18793
+ // For tools with a glob/include pattern
18794
+ if (input.include && typeof input.include === "string") {
18795
+ return `${tool} -- ${truncate(input.include, 80)}`;
18796
+ }
18797
+ return tool;
18798
+ }
18799
+ /**
18800
+ * Send a message to Telegram, with Markdown fallback.
18801
+ * When disableLinkPreview is true, link previews are suppressed (useful for verbose messages).
18802
+ */
18803
+ async function sendTelegramMessage(chatId, text, disableLinkPreview = false) {
18804
+ const options = {
18805
+ parse_mode: "Markdown",
18806
+ };
18807
+ if (disableLinkPreview) {
18808
+ options.link_preview_options = { is_disabled: true };
18809
+ }
18810
+ try {
18811
+ await bot.telegram.sendMessage(chatId, text, options);
18812
+ }
18813
+ catch {
18814
+ try {
18815
+ const fallbackOptions = {};
18816
+ if (disableLinkPreview) {
18817
+ fallbackOptions.link_preview_options = { is_disabled: true };
18818
+ }
18819
+ await bot.telegram.sendMessage(chatId, text, fallbackOptions);
18820
+ }
18821
+ catch (fallbackErr) {
18822
+ console.error("[Telegram] Fallback error:", fallbackErr);
18823
+ }
18824
+ }
18825
+ }
18761
18826
  /**
18762
18827
  * Extract text from response parts and send to Telegram chat.
18763
18828
  */
@@ -18781,20 +18846,145 @@ async function startTelegram(options) {
18781
18846
  // Send the response, splitting if needed (Telegram has a 4096 char limit)
18782
18847
  const chunks = splitMessage(responseText, 4096);
18783
18848
  for (const chunk of chunks) {
18784
- try {
18785
- await bot.telegram.sendMessage(chatId, chunk, {
18786
- parse_mode: "Markdown",
18787
- });
18788
- }
18789
- catch {
18849
+ await sendTelegramMessage(chatId, chunk);
18850
+ }
18851
+ }
18852
+ /**
18853
+ * Send a prompt with streaming: fires the prompt asynchronously, then listens
18854
+ * to SSE events to stream thinking and tool call updates to Telegram in real-time.
18855
+ * The final text response is sent when the session goes idle.
18856
+ */
18857
+ async function sendPromptStreaming(chatId, sessionId, promptBody, processingMsgId, verbose = false) {
18858
+ // Subscribe to SSE events before sending the prompt so we don't miss anything
18859
+ const abortController = new AbortController();
18860
+ // Subscribe to SSE events before sending the prompt so we don't miss anything
18861
+ const subscribeOptions = {
18862
+ signal: abortController.signal,
18863
+ };
18864
+ if (projectDirectory) {
18865
+ subscribeOptions.query = { directory: projectDirectory };
18866
+ }
18867
+ const { stream } = await client.event.subscribe(subscribeOptions);
18868
+ // Fire the prompt asynchronously (returns immediately)
18869
+ const asyncResult = await client.session.promptAsync({
18870
+ path: { id: sessionId },
18871
+ body: promptBody,
18872
+ });
18873
+ if (asyncResult.error) {
18874
+ abortController.abort();
18875
+ throw new Error(`Prompt failed: ${JSON.stringify(asyncResult.error)}`);
18876
+ }
18877
+ // Track state as events stream in
18878
+ let finalText = "";
18879
+ let processingMsgDeleted = false;
18880
+ const sentToolIds = new Set();
18881
+ const sentReasoningIds = new Set();
18882
+ // Buffer the latest reasoning text -- we send it when a non-reasoning part arrives
18883
+ // so we get the complete thinking block rather than a partial one
18884
+ let pendingReasoning = null;
18885
+ /**
18886
+ * Delete the "processing" message once, right before sending the first real output.
18887
+ */
18888
+ async function deleteProcessingMsg() {
18889
+ if (!processingMsgDeleted) {
18890
+ processingMsgDeleted = true;
18790
18891
  try {
18791
- await bot.telegram.sendMessage(chatId, chunk);
18892
+ await bot.telegram.deleteMessage(chatId, processingMsgId);
18893
+ }
18894
+ catch {
18895
+ // Ignore
18896
+ }
18897
+ }
18898
+ }
18899
+ /**
18900
+ * Flush any buffered reasoning to Telegram as a spoiler message.
18901
+ */
18902
+ async function flushReasoning() {
18903
+ if (verbose && pendingReasoning && !sentReasoningIds.has(pendingReasoning.id)) {
18904
+ sentReasoningIds.add(pendingReasoning.id);
18905
+ await deleteProcessingMsg();
18906
+ const truncated = truncate(pendingReasoning.text.replace(/\n/g, " "), 500);
18907
+ await sendTelegramMessage(chatId, `\u{1F9E0} Thinking: ${truncated}`, true);
18908
+ }
18909
+ pendingReasoning = null;
18910
+ }
18911
+ try {
18912
+ for await (const event of stream) {
18913
+ const ev = event;
18914
+ if (ev.type === "message.part.updated" && ev.properties) {
18915
+ const part = ev.properties.part;
18916
+ // Only process events for our session
18917
+ if (part.sessionID !== sessionId)
18918
+ continue;
18919
+ const partText = part.text;
18920
+ if (part.type === "reasoning" && part.id) {
18921
+ // Buffer reasoning -- keep updating with the latest full text
18922
+ if (partText) {
18923
+ pendingReasoning = { id: part.id, text: partText };
18924
+ }
18925
+ }
18926
+ else if (part.type === "tool" && part.id) {
18927
+ // A tool part arrived -- flush any pending reasoning first
18928
+ await flushReasoning();
18929
+ if (verbose) {
18930
+ const state = part.state;
18931
+ // Only send tool messages once they have a completed/error status
18932
+ if (state &&
18933
+ (state.status === "completed" || state.status === "error") &&
18934
+ !sentToolIds.has(part.id)) {
18935
+ sentToolIds.add(part.id);
18936
+ await deleteProcessingMsg();
18937
+ const tool = part.tool;
18938
+ const summary = summarizeTool(tool, state.input);
18939
+ let line = `\u{2699}\u{FE0F} ${summary}`;
18940
+ if (state.status === "error") {
18941
+ line += ` \u{274C}`;
18942
+ }
18943
+ await sendTelegramMessage(chatId, line, true);
18944
+ }
18945
+ }
18946
+ }
18947
+ else if (part.type === "text") {
18948
+ // A text part arrived -- flush any pending reasoning first
18949
+ await flushReasoning();
18950
+ // Accumulate text for the final response (text parts carry the full text, not deltas)
18951
+ const text = part.text;
18952
+ if (text) {
18953
+ finalText = text;
18954
+ }
18955
+ }
18792
18956
  }
18793
- catch (fallbackErr) {
18794
- console.error("[Telegram] Fallback error:", fallbackErr);
18957
+ else if (ev.type === "session.idle" && ev.properties) {
18958
+ const idleSessionId = ev.properties.sessionID;
18959
+ if (idleSessionId === sessionId) {
18960
+ // Flush any remaining reasoning before finishing
18961
+ await flushReasoning();
18962
+ break;
18963
+ }
18964
+ }
18965
+ else if (ev.type === "session.error" && ev.properties) {
18966
+ const errorSessionId = ev.properties.sessionID;
18967
+ if (errorSessionId === sessionId) {
18968
+ const error = ev.properties.error;
18969
+ const errorMsg = error?.data?.message || "Unknown error";
18970
+ throw new Error(`Session error: ${errorMsg}`);
18971
+ }
18795
18972
  }
18796
18973
  }
18797
18974
  }
18975
+ finally {
18976
+ abortController.abort();
18977
+ }
18978
+ // Delete the processing message if it hasn't been deleted yet (non-verbose mode)
18979
+ await deleteProcessingMsg();
18980
+ // Send the final text response
18981
+ if (!finalText) {
18982
+ finalText = "The agent returned an empty response.";
18983
+ }
18984
+ const chunks = splitMessage(finalText, 4096);
18985
+ for (const chunk of chunks) {
18986
+ await sendTelegramMessage(chatId, chunk);
18987
+ }
18798
18988
  }
18799
18989
  /**
18800
18990
  * Handle session errors - clear invalid sessions.
@@ -18847,6 +19037,8 @@ async function startTelegram(options) {
18847
19037
  "/delete <number> - Delete a session\n" +
18848
19038
  "/export - Export the current session as a markdown file\n" +
18849
19039
  "/export full - Export with all details (thinking, costs, steps)\n" +
19040
+ "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
19041
+ "/verbose on|off - Set verbose mode explicitly\n" +
18850
19042
  "/help - Show this help message\n";
18851
19043
  if (opencodeCommands.size > 0) {
18852
19044
  msg +=
@@ -18866,6 +19058,8 @@ async function startTelegram(options) {
18866
19058
  "/delete <number> - Delete a session\n" +
18867
19059
  "/export - Export the current session as a markdown file\n" +
18868
19060
  "/export full - Export with all details (thinking, costs, steps)\n" +
19061
+ "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
19062
+ "/verbose on|off - Set verbose mode explicitly\n" +
18869
19063
  "/help - Show this help message\n";
18870
19064
  if (opencodeCommands.size > 0) {
18871
19065
  msg +=
@@ -19025,6 +19219,32 @@ async function startTelegram(options) {
19025
19219
  await ctx.reply("Failed to delete session.");
19026
19220
  }
19027
19221
  });
19222
+ // Handle /verbose command - toggle verbose mode for this chat
19223
+ // Usage: /verbose, /verbose on, /verbose off
19224
+ bot.command("verbose", (ctx) => {
19225
+ const chatId = ctx.chat.id.toString();
19226
+ const args = ctx.message.text.replace(/^\/verbose\s*/, "").trim().toLowerCase();
19227
+ if (args === "on") {
19228
+ chatVerboseMode.add(chatId);
19229
+ }
19230
+ else if (args === "off") {
19231
+ chatVerboseMode.delete(chatId);
19232
+ }
19233
+ else {
19234
+ if (chatVerboseMode.has(chatId)) {
19235
+ chatVerboseMode.delete(chatId);
19236
+ }
19237
+ else {
19238
+ chatVerboseMode.add(chatId);
19239
+ }
19240
+ }
19241
+ saveSessions();
19242
+ const enabled = chatVerboseMode.has(chatId);
19243
+ console.log(`[Telegram] Verbose mode ${enabled ? "enabled" : "disabled"} for chat ${chatId}`);
19244
+ ctx.reply(enabled
19245
+ ? "Verbose mode enabled. Responses will include thinking and tool calls."
19246
+ : "Verbose mode disabled. Responses will only show the assistant's text.");
19247
+ });
19028
19248
  /**
19029
19249
  * Render a message part to markdown.
19030
19250
  * In default mode: only text and tool calls (name + input/output).
@@ -19316,15 +19536,8 @@ async function startTelegram(options) {
19316
19536
  const modelID = modelParts.join("/");
19317
19537
  promptBody.model = { providerID, modelID };
19318
19538
  }
19319
- const result = await client.session.prompt({
19320
- path: { id: sessionId },
19321
- body: promptBody,
19322
- });
19323
- if (result.error) {
19324
- throw new Error(`Prompt failed: ${JSON.stringify(result.error)}`);
19325
- }
19326
- const parts = (result.data?.parts || []);
19327
- await sendResponseToChat(ctx.chat.id, parts, processingMsg.message_id);
19539
+ const verbose = chatVerboseMode.has(chatId);
19540
+ await sendPromptStreaming(ctx.chat.id, sessionId, promptBody, processingMsg.message_id, verbose);
19328
19541
  }
19329
19542
  catch (err) {
19330
19543
  console.error("[Telegram] Error processing message:", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-bot",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "description": "Telegram bot that forwards messages to an OpenCode agent",
6
6
  "main": "./dist/index.js",