opencode-telegram-bot 1.0.1 → 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 +35 -0
  2. package/dist/index.js +466 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -103,8 +103,43 @@ These are handled directly by the Telegram bot:
103
103
  | `/switch <id>` | Switch to a different session (prefix match supported) |
104
104
  | `/title <text>` | Rename the current session |
105
105
  | `/delete <id>` | Delete a session from the server |
106
+ | `/export` | Export the current session as a markdown file |
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 |
106
110
  | `/help` | Show available commands |
107
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
+
132
+ ### Session Export
133
+
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.
135
+
136
+ Two modes are available:
137
+
138
+ - **`/export`** -- Default. Includes user messages, assistant text, and tool calls (name, input, output).
139
+ - **`/export full`** (also accepts `detailed` or `all`) -- Includes everything from the default mode plus reasoning/thinking blocks, step boundaries with token counts, costs, subtasks, retries, and compaction markers.
140
+
141
+ The exported file is named `session-<id>.md` (or `session-<id>-detailed.md` for the full export).
142
+
108
143
  ### OpenCode Commands
109
144
 
110
145
  Any `/` command that isn't a bot command is automatically forwarded to the OpenCode server via its command system. The bot fetches the list of available commands on startup and validates them.
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",
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);
18792
18893
  }
18793
- catch (fallbackErr) {
18794
- console.error("[Telegram] Fallback error:", fallbackErr);
18894
+ catch {
18895
+ // Ignore
18795
18896
  }
18796
18897
  }
18797
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
+ }
18956
+ }
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
+ }
18972
+ }
18973
+ }
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.
@@ -18845,6 +19035,10 @@ async function startTelegram(options) {
18845
19035
  "/switch <number> - Switch to a different session\n" +
18846
19036
  "/title <text> - Rename the current session\n" +
18847
19037
  "/delete <number> - Delete a session\n" +
19038
+ "/export - Export the current session as a markdown file\n" +
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" +
18848
19042
  "/help - Show this help message\n";
18849
19043
  if (opencodeCommands.size > 0) {
18850
19044
  msg +=
@@ -18862,6 +19056,10 @@ async function startTelegram(options) {
18862
19056
  "/switch <number> - Switch to a different session\n" +
18863
19057
  "/title <text> - Rename the current session\n" +
18864
19058
  "/delete <number> - Delete a session\n" +
19059
+ "/export - Export the current session as a markdown file\n" +
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" +
18865
19063
  "/help - Show this help message\n";
18866
19064
  if (opencodeCommands.size > 0) {
18867
19065
  msg +=
@@ -19021,6 +19219,260 @@ async function startTelegram(options) {
19021
19219
  await ctx.reply("Failed to delete session.");
19022
19220
  }
19023
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
+ });
19248
+ /**
19249
+ * Render a message part to markdown.
19250
+ * In default mode: only text and tool calls (name + input/output).
19251
+ * In detailed mode: also includes reasoning, step info, subtasks, costs, etc.
19252
+ */
19253
+ function renderPart(part, detailed) {
19254
+ let md = "";
19255
+ switch (part.type) {
19256
+ case "text": {
19257
+ const text = part.text;
19258
+ if (text) {
19259
+ md += `${text}\n\n`;
19260
+ }
19261
+ break;
19262
+ }
19263
+ case "tool": {
19264
+ const tool = part.tool;
19265
+ const state = part.state;
19266
+ md += `**Tool: ${tool}**\n\n`;
19267
+ if (state.input) {
19268
+ md += `**Input:**\n\`\`\`json\n${JSON.stringify(state.input, null, 2)}\n\`\`\`\n\n`;
19269
+ }
19270
+ if (state.output) {
19271
+ md += `**Output:**\n\`\`\`\n${state.output}\n\`\`\`\n\n`;
19272
+ }
19273
+ if (state.error) {
19274
+ md += `**Error:**\n\`\`\`\n${state.error}\n\`\`\`\n\n`;
19275
+ }
19276
+ if (detailed && state.time) {
19277
+ const duration = ((state.time.end - state.time.start) / 1000).toFixed(1);
19278
+ md += `*Status: ${state.status} (${duration}s)*\n\n`;
19279
+ }
19280
+ break;
19281
+ }
19282
+ case "reasoning": {
19283
+ if (!detailed)
19284
+ break;
19285
+ const text = part.text;
19286
+ if (text) {
19287
+ md += `<details>\n<summary>Thinking</summary>\n\n${text}\n\n</details>\n\n`;
19288
+ }
19289
+ break;
19290
+ }
19291
+ case "step-start": {
19292
+ if (!detailed)
19293
+ break;
19294
+ md += `---\n*Step started*\n\n`;
19295
+ break;
19296
+ }
19297
+ case "step-finish": {
19298
+ if (!detailed)
19299
+ break;
19300
+ const reason = part.reason;
19301
+ const cost = part.cost;
19302
+ const tokens = part.tokens;
19303
+ let info = `*Step finished`;
19304
+ if (reason)
19305
+ info += ` (${reason})`;
19306
+ info += `*`;
19307
+ if (tokens) {
19308
+ info += `\n*Tokens: ${tokens.input} in / ${tokens.output} out`;
19309
+ if (tokens.reasoning > 0)
19310
+ info += ` / ${tokens.reasoning} reasoning`;
19311
+ if (tokens.cache.read > 0 || tokens.cache.write > 0) {
19312
+ info += ` (cache: ${tokens.cache.read} read, ${tokens.cache.write} write)`;
19313
+ }
19314
+ info += `*`;
19315
+ }
19316
+ if (cost !== undefined && cost > 0) {
19317
+ info += `\n*Cost: $${cost.toFixed(4)}*`;
19318
+ }
19319
+ md += `${info}\n\n---\n\n`;
19320
+ break;
19321
+ }
19322
+ case "subtask": {
19323
+ if (!detailed)
19324
+ break;
19325
+ const description = part.description;
19326
+ const agent = part.agent;
19327
+ const prompt = part.prompt;
19328
+ md += `**Subtask${agent ? ` (${agent})` : ""}**`;
19329
+ if (description)
19330
+ md += `: ${description}`;
19331
+ md += `\n\n`;
19332
+ if (prompt) {
19333
+ md += `> ${prompt.replace(/\n/g, "\n> ")}\n\n`;
19334
+ }
19335
+ break;
19336
+ }
19337
+ case "agent": {
19338
+ if (!detailed)
19339
+ break;
19340
+ const name = part.name;
19341
+ if (name) {
19342
+ md += `*Agent: ${name}*\n\n`;
19343
+ }
19344
+ break;
19345
+ }
19346
+ case "retry": {
19347
+ if (!detailed)
19348
+ break;
19349
+ const attempt = part.attempt;
19350
+ const error = part.error;
19351
+ md += `**Retry (attempt ${attempt || "?"})**`;
19352
+ if (error?.data?.message) {
19353
+ md += `\n\`\`\`\n${error.data.message}\n\`\`\``;
19354
+ }
19355
+ md += `\n\n`;
19356
+ break;
19357
+ }
19358
+ case "compaction": {
19359
+ if (!detailed)
19360
+ break;
19361
+ const auto = part.auto;
19362
+ md += `*Context compacted${auto ? " (auto)" : ""}*\n\n`;
19363
+ break;
19364
+ }
19365
+ default:
19366
+ // snapshot, patch, file, etc. - skip in both modes
19367
+ break;
19368
+ }
19369
+ return md;
19370
+ }
19371
+ // Handle /export command - export the current session to the opencode project directory
19372
+ // Usage: /export - default (text + tool calls only)
19373
+ // /export full - detailed (includes thinking, steps, costs, subtasks, etc.)
19374
+ bot.command("export", async (ctx) => {
19375
+ const chatId = ctx.chat.id.toString();
19376
+ const sessionId = chatSessions.get(chatId);
19377
+ if (!sessionId) {
19378
+ await ctx.reply("No active session. Send a message first to create one.");
19379
+ return;
19380
+ }
19381
+ const args = ctx.message.text.replace(/^\/export\s*/, "").trim().toLowerCase();
19382
+ const detailed = args === "full" || args === "detailed" || args === "all";
19383
+ try {
19384
+ const modeLabel = detailed ? "detailed" : "summary";
19385
+ const processingMsg = await ctx.reply(`Exporting session (${modeLabel})...`);
19386
+ // Fetch session info and messages in parallel
19387
+ const [sessionResult, messagesResult, pathResult] = await Promise.all([
19388
+ client.session.get({ path: { id: sessionId } }),
19389
+ client.session.messages({ path: { id: sessionId } }),
19390
+ client.path.get(),
19391
+ ]);
19392
+ if (sessionResult.error || !sessionResult.data) {
19393
+ throw new Error(`Failed to get session: ${JSON.stringify(sessionResult.error)}`);
19394
+ }
19395
+ if (messagesResult.error || !messagesResult.data) {
19396
+ throw new Error(`Failed to get messages: ${JSON.stringify(messagesResult.error)}`);
19397
+ }
19398
+ const session = sessionResult.data;
19399
+ const messages = messagesResult.data;
19400
+ // Build the markdown export
19401
+ let md = `# ${session.title}\n\n`;
19402
+ md += `**Session ID:** ${session.id}\n`;
19403
+ md += `**Created:** ${new Date(session.time.created * 1000).toLocaleString()}\n`;
19404
+ md += `**Updated:** ${new Date(session.time.updated * 1000).toLocaleString()}\n`;
19405
+ if (detailed) {
19406
+ md += `**Export mode:** Detailed\n`;
19407
+ }
19408
+ md += `\n---\n\n`;
19409
+ for (const msg of messages) {
19410
+ const info = msg.info;
19411
+ const parts = msg.parts || [];
19412
+ if (info.role === "user") {
19413
+ md += `## User\n\n`;
19414
+ for (const part of parts) {
19415
+ md += renderPart(part, detailed);
19416
+ }
19417
+ md += `---\n\n`;
19418
+ }
19419
+ else if (info.role === "assistant") {
19420
+ const assistant = info;
19421
+ const duration = assistant.time.completed
19422
+ ? ((assistant.time.completed - assistant.time.created) / 1000).toFixed(1) + "s"
19423
+ : "";
19424
+ const modelLabel = assistant.modelID || "unknown";
19425
+ const modeLabel = assistant.mode || "";
19426
+ const header = [modeLabel, modelLabel, duration].filter(Boolean).join(" · ");
19427
+ md += `## Assistant (${header})\n\n`;
19428
+ if (detailed && assistant.tokens) {
19429
+ const t = assistant.tokens;
19430
+ md += `*Tokens: ${t.input} in / ${t.output} out`;
19431
+ if (t.reasoning > 0)
19432
+ md += ` / ${t.reasoning} reasoning`;
19433
+ if (t.cache.read > 0 || t.cache.write > 0) {
19434
+ md += ` (cache: ${t.cache.read} read, ${t.cache.write} write)`;
19435
+ }
19436
+ md += `*\n`;
19437
+ if (assistant.cost && assistant.cost > 0) {
19438
+ md += `*Cost: $${assistant.cost.toFixed(4)}*\n`;
19439
+ }
19440
+ md += `\n`;
19441
+ }
19442
+ for (const part of parts) {
19443
+ md += renderPart(part, detailed);
19444
+ }
19445
+ md += `---\n\n`;
19446
+ }
19447
+ }
19448
+ // Write to the project directory
19449
+ const projectDir = pathResult.data?.directory;
19450
+ const idPrefix = sessionId.substring(0, 8);
19451
+ const suffix = detailed ? "-detailed" : "";
19452
+ const filename = `session-${idPrefix}${suffix}.md`;
19453
+ const exportPath = (0,external_node_path_.resolve)(projectDir || ".", filename);
19454
+ (0,external_node_fs_.writeFileSync)(exportPath, md, "utf-8");
19455
+ console.log(`[Telegram] Exported session ${sessionId} to ${exportPath} (${detailed ? "detailed" : "summary"})`);
19456
+ // Delete the processing message
19457
+ try {
19458
+ await bot.telegram.deleteMessage(ctx.chat.id, processingMsg.message_id);
19459
+ }
19460
+ catch {
19461
+ // Ignore if we can't delete it
19462
+ }
19463
+ // Send the file to the user
19464
+ await bot.telegram.sendDocument(ctx.chat.id, {
19465
+ source: Buffer.from(md, "utf-8"),
19466
+ filename,
19467
+ });
19468
+ await ctx.reply(`Session exported to: ${exportPath}`);
19469
+ }
19470
+ catch (err) {
19471
+ console.error("[Telegram] Error exporting session:", err);
19472
+ await handleSessionError(chatId);
19473
+ await ctx.reply("Sorry, there was an error exporting the session. Try again or use /new to start a fresh session.");
19474
+ }
19475
+ });
19024
19476
  // Catch-all for unregistered / commands - forward to OpenCode
19025
19477
  bot.on("text", async (ctx) => {
19026
19478
  const userText = ctx.message.text;
@@ -19052,6 +19504,7 @@ async function startTelegram(options) {
19052
19504
  body: {
19053
19505
  command: commandName,
19054
19506
  arguments: commandArgs,
19507
+ agent: "default",
19055
19508
  },
19056
19509
  });
19057
19510
  if (result.error) {
@@ -19083,15 +19536,8 @@ async function startTelegram(options) {
19083
19536
  const modelID = modelParts.join("/");
19084
19537
  promptBody.model = { providerID, modelID };
19085
19538
  }
19086
- const result = await client.session.prompt({
19087
- path: { id: sessionId },
19088
- body: promptBody,
19089
- });
19090
- if (result.error) {
19091
- throw new Error(`Prompt failed: ${JSON.stringify(result.error)}`);
19092
- }
19093
- const parts = (result.data?.parts || []);
19094
- 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);
19095
19541
  }
19096
19542
  catch (err) {
19097
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.1",
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",