opencode-telegram-bot 1.0.0 → 1.0.2

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 +13 -0
  2. package/dist/index.js +279 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -103,8 +103,21 @@ 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) |
106
108
  | `/help` | Show available commands |
107
109
 
110
+ ### Session Export
111
+
112
+ 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.
113
+
114
+ Two modes are available:
115
+
116
+ - **`/export`** -- Default. Includes user messages, assistant text, and tool calls (name, input, output).
117
+ - **`/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.
118
+
119
+ The exported file is named `session-<id>.md` (or `session-<id>-detailed.md` for the full export).
120
+
108
121
  ### OpenCode Commands
109
122
 
110
123
  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
@@ -18608,7 +18608,7 @@ async function startTelegram(options) {
18608
18608
  }
18609
18609
  // Telegram-only commands that should not be forwarded to OpenCode
18610
18610
  const telegramCommands = new Set([
18611
- "start", "help", "new", "sessions", "switch", "title", "delete",
18611
+ "start", "help", "new", "sessions", "switch", "title", "delete", "export",
18612
18612
  ]);
18613
18613
  // Map of chatId -> sessionId for the active session per chat
18614
18614
  const chatSessions = new Map();
@@ -18842,9 +18842,11 @@ async function startTelegram(options) {
18842
18842
  "Bot commands:\n" +
18843
18843
  "/new - Start a new conversation\n" +
18844
18844
  "/sessions - List your sessions\n" +
18845
- "/switch <id> - Switch to a different session\n" +
18845
+ "/switch <number> - Switch to a different session\n" +
18846
18846
  "/title <text> - Rename the current session\n" +
18847
- "/delete <id> - Delete a session\n" +
18847
+ "/delete <number> - Delete a session\n" +
18848
+ "/export - Export the current session as a markdown file\n" +
18849
+ "/export full - Export with all details (thinking, costs, steps)\n" +
18848
18850
  "/help - Show this help message\n";
18849
18851
  if (opencodeCommands.size > 0) {
18850
18852
  msg +=
@@ -18859,9 +18861,11 @@ async function startTelegram(options) {
18859
18861
  "Bot commands:\n" +
18860
18862
  "/new - Start a new conversation\n" +
18861
18863
  "/sessions - List your sessions\n" +
18862
- "/switch <id> - Switch to a different session\n" +
18864
+ "/switch <number> - Switch to a different session\n" +
18863
18865
  "/title <text> - Rename the current session\n" +
18864
- "/delete <id> - Delete a session\n" +
18866
+ "/delete <number> - Delete a session\n" +
18867
+ "/export - Export the current session as a markdown file\n" +
18868
+ "/export full - Export with all details (thinking, costs, steps)\n" +
18865
18869
  "/help - Show this help message\n";
18866
18870
  if (opencodeCommands.size > 0) {
18867
18871
  msg +=
@@ -18878,34 +18882,49 @@ async function startTelegram(options) {
18878
18882
  console.log(`[Telegram] Session cleared for chat ${chatId}`);
18879
18883
  ctx.reply("Conversation reset. Your next message will start a new session.");
18880
18884
  });
18885
+ /**
18886
+ * Get the list of known sessions, sorted by most recently updated.
18887
+ */
18888
+ async function getKnownSessions() {
18889
+ const list = await client.session.list();
18890
+ if (!list.data || list.data.length === 0)
18891
+ return [];
18892
+ return list.data
18893
+ .filter((s) => knownSessionIds.has(s.id))
18894
+ .sort((a, b) => b.time.updated - a.time.updated);
18895
+ }
18896
+ /**
18897
+ * Resolve a user argument to a session. Accepts either a numeric index
18898
+ * (1-based, as shown by /sessions) or a session ID / prefix.
18899
+ */
18900
+ function resolveSession(sessions, arg) {
18901
+ // Try numeric index first
18902
+ const num = parseInt(arg, 10);
18903
+ if (!isNaN(num) && String(num) === arg && num >= 1 && num <= sessions.length) {
18904
+ return sessions[num - 1];
18905
+ }
18906
+ // Fall back to ID / prefix match
18907
+ return sessions.find((s) => s.id === arg || s.id.startsWith(arg));
18908
+ }
18881
18909
  // Handle /sessions command - list Telegram bot sessions only
18882
18910
  bot.command("sessions", async (ctx) => {
18883
18911
  const chatId = ctx.chat.id.toString();
18884
18912
  const activeSessionId = chatSessions.get(chatId);
18885
18913
  try {
18886
- const list = await client.session.list();
18887
- if (!list.data || list.data.length === 0) {
18888
- await ctx.reply("No sessions found.");
18889
- return;
18890
- }
18891
- // Filter to only sessions created by this bot
18892
- const sessions = list.data
18893
- .filter((s) => knownSessionIds.has(s.id))
18894
- .sort((a, b) => b.time.updated - a.time.updated);
18914
+ const sessions = await getKnownSessions();
18895
18915
  if (sessions.length === 0) {
18896
18916
  await ctx.reply("No sessions found.");
18897
18917
  return;
18898
18918
  }
18899
18919
  let msg = "Your sessions:\n\n";
18900
- for (const session of sessions) {
18920
+ sessions.forEach((session, i) => {
18901
18921
  const isActive = session.id === activeSessionId;
18902
18922
  const age = formatAge(session.time.updated);
18903
- const shortId = session.id.substring(0, 8);
18904
- const marker = isActive ? "[active] " : "";
18905
- msg += `${marker}${session.title} - ${age} (${shortId})\n`;
18906
- }
18907
- msg += `\nUse /switch <id> to switch sessions.`;
18908
- msg += `\nUse /delete <id> to delete a session.`;
18923
+ const marker = isActive ? " [active]" : "";
18924
+ msg += `${i + 1}. ${session.title} - ${age}${marker}\n`;
18925
+ });
18926
+ msg += `\nUse /switch <number> to switch sessions.`;
18927
+ msg += `\nUse /delete <number> to delete a session.`;
18909
18928
  await ctx.reply(msg);
18910
18929
  }
18911
18930
  catch (err) {
@@ -18913,24 +18932,17 @@ async function startTelegram(options) {
18913
18932
  await ctx.reply("Failed to list sessions.");
18914
18933
  }
18915
18934
  });
18916
- // Handle /switch <id> command - switch to a different session
18935
+ // Handle /switch <number> command - switch to a different session
18917
18936
  bot.command("switch", async (ctx) => {
18918
18937
  const chatId = ctx.chat.id.toString();
18919
18938
  const args = ctx.message.text.replace(/^\/switch\s*/, "").trim();
18920
18939
  if (!args) {
18921
- await ctx.reply("Usage: /switch <session-id>\n\nUse /sessions to see available sessions.");
18940
+ await ctx.reply("Usage: /switch <number>\n\nUse /sessions to see available sessions.");
18922
18941
  return;
18923
18942
  }
18924
18943
  try {
18925
- // Fetch all sessions and find one matching the provided prefix
18926
- const list = await client.session.list();
18927
- if (!list.data) {
18928
- await ctx.reply("Failed to fetch sessions from the server.");
18929
- return;
18930
- }
18931
- const match = list.data
18932
- .filter((s) => knownSessionIds.has(s.id))
18933
- .find((s) => s.id === args || s.id.startsWith(args));
18944
+ const sessions = await getKnownSessions();
18945
+ const match = resolveSession(sessions, args);
18934
18946
  if (!match) {
18935
18947
  await ctx.reply(`No session found matching "${args}".\n\nUse /sessions to see available sessions.`);
18936
18948
  return;
@@ -18938,8 +18950,7 @@ async function startTelegram(options) {
18938
18950
  chatSessions.set(chatId, match.id);
18939
18951
  saveSessions();
18940
18952
  console.log(`[Telegram] Switched chat ${chatId} to session ${match.id}`);
18941
- const shortId = match.id.substring(0, 8);
18942
- await ctx.reply(`Switched to session: ${match.title} (${shortId})`);
18953
+ await ctx.reply(`Switched to session: ${match.title}`);
18943
18954
  }
18944
18955
  catch (err) {
18945
18956
  console.error("[Telegram] Error switching session:", err);
@@ -18975,24 +18986,17 @@ async function startTelegram(options) {
18975
18986
  await ctx.reply("Failed to rename session.");
18976
18987
  }
18977
18988
  });
18978
- // Handle /delete <id> command - delete a session
18989
+ // Handle /delete <number> command - delete a session
18979
18990
  bot.command("delete", async (ctx) => {
18980
18991
  const chatId = ctx.chat.id.toString();
18981
18992
  const args = ctx.message.text.replace(/^\/delete\s*/, "").trim();
18982
18993
  if (!args) {
18983
- await ctx.reply("Usage: /delete <session-id>\n\nUse /sessions to see available sessions.");
18994
+ await ctx.reply("Usage: /delete <number>\n\nUse /sessions to see available sessions.");
18984
18995
  return;
18985
18996
  }
18986
18997
  try {
18987
- // Fetch all sessions and find one matching the provided prefix
18988
- const list = await client.session.list();
18989
- if (!list.data) {
18990
- await ctx.reply("Failed to fetch sessions from the server.");
18991
- return;
18992
- }
18993
- const match = list.data
18994
- .filter((s) => knownSessionIds.has(s.id))
18995
- .find((s) => s.id === args || s.id.startsWith(args));
18998
+ const sessions = await getKnownSessions();
18999
+ const match = resolveSession(sessions, args);
18996
19000
  if (!match) {
18997
19001
  await ctx.reply(`No session found matching "${args}".\n\nUse /sessions to see available sessions.`);
18998
19002
  return;
@@ -19013,15 +19017,242 @@ async function startTelegram(options) {
19013
19017
  // Remove from known sessions
19014
19018
  knownSessionIds.delete(match.id);
19015
19019
  saveSessions();
19016
- const shortId = match.id.substring(0, 8);
19017
19020
  console.log(`[Telegram] Deleted session ${match.id}`);
19018
- await ctx.reply(`Deleted session: ${match.title} (${shortId})`);
19021
+ await ctx.reply(`Deleted session: ${match.title}`);
19019
19022
  }
19020
19023
  catch (err) {
19021
19024
  console.error("[Telegram] Error deleting session:", err);
19022
19025
  await ctx.reply("Failed to delete session.");
19023
19026
  }
19024
19027
  });
19028
+ /**
19029
+ * Render a message part to markdown.
19030
+ * In default mode: only text and tool calls (name + input/output).
19031
+ * In detailed mode: also includes reasoning, step info, subtasks, costs, etc.
19032
+ */
19033
+ function renderPart(part, detailed) {
19034
+ let md = "";
19035
+ switch (part.type) {
19036
+ case "text": {
19037
+ const text = part.text;
19038
+ if (text) {
19039
+ md += `${text}\n\n`;
19040
+ }
19041
+ break;
19042
+ }
19043
+ case "tool": {
19044
+ const tool = part.tool;
19045
+ const state = part.state;
19046
+ md += `**Tool: ${tool}**\n\n`;
19047
+ if (state.input) {
19048
+ md += `**Input:**\n\`\`\`json\n${JSON.stringify(state.input, null, 2)}\n\`\`\`\n\n`;
19049
+ }
19050
+ if (state.output) {
19051
+ md += `**Output:**\n\`\`\`\n${state.output}\n\`\`\`\n\n`;
19052
+ }
19053
+ if (state.error) {
19054
+ md += `**Error:**\n\`\`\`\n${state.error}\n\`\`\`\n\n`;
19055
+ }
19056
+ if (detailed && state.time) {
19057
+ const duration = ((state.time.end - state.time.start) / 1000).toFixed(1);
19058
+ md += `*Status: ${state.status} (${duration}s)*\n\n`;
19059
+ }
19060
+ break;
19061
+ }
19062
+ case "reasoning": {
19063
+ if (!detailed)
19064
+ break;
19065
+ const text = part.text;
19066
+ if (text) {
19067
+ md += `<details>\n<summary>Thinking</summary>\n\n${text}\n\n</details>\n\n`;
19068
+ }
19069
+ break;
19070
+ }
19071
+ case "step-start": {
19072
+ if (!detailed)
19073
+ break;
19074
+ md += `---\n*Step started*\n\n`;
19075
+ break;
19076
+ }
19077
+ case "step-finish": {
19078
+ if (!detailed)
19079
+ break;
19080
+ const reason = part.reason;
19081
+ const cost = part.cost;
19082
+ const tokens = part.tokens;
19083
+ let info = `*Step finished`;
19084
+ if (reason)
19085
+ info += ` (${reason})`;
19086
+ info += `*`;
19087
+ if (tokens) {
19088
+ info += `\n*Tokens: ${tokens.input} in / ${tokens.output} out`;
19089
+ if (tokens.reasoning > 0)
19090
+ info += ` / ${tokens.reasoning} reasoning`;
19091
+ if (tokens.cache.read > 0 || tokens.cache.write > 0) {
19092
+ info += ` (cache: ${tokens.cache.read} read, ${tokens.cache.write} write)`;
19093
+ }
19094
+ info += `*`;
19095
+ }
19096
+ if (cost !== undefined && cost > 0) {
19097
+ info += `\n*Cost: $${cost.toFixed(4)}*`;
19098
+ }
19099
+ md += `${info}\n\n---\n\n`;
19100
+ break;
19101
+ }
19102
+ case "subtask": {
19103
+ if (!detailed)
19104
+ break;
19105
+ const description = part.description;
19106
+ const agent = part.agent;
19107
+ const prompt = part.prompt;
19108
+ md += `**Subtask${agent ? ` (${agent})` : ""}**`;
19109
+ if (description)
19110
+ md += `: ${description}`;
19111
+ md += `\n\n`;
19112
+ if (prompt) {
19113
+ md += `> ${prompt.replace(/\n/g, "\n> ")}\n\n`;
19114
+ }
19115
+ break;
19116
+ }
19117
+ case "agent": {
19118
+ if (!detailed)
19119
+ break;
19120
+ const name = part.name;
19121
+ if (name) {
19122
+ md += `*Agent: ${name}*\n\n`;
19123
+ }
19124
+ break;
19125
+ }
19126
+ case "retry": {
19127
+ if (!detailed)
19128
+ break;
19129
+ const attempt = part.attempt;
19130
+ const error = part.error;
19131
+ md += `**Retry (attempt ${attempt || "?"})**`;
19132
+ if (error?.data?.message) {
19133
+ md += `\n\`\`\`\n${error.data.message}\n\`\`\``;
19134
+ }
19135
+ md += `\n\n`;
19136
+ break;
19137
+ }
19138
+ case "compaction": {
19139
+ if (!detailed)
19140
+ break;
19141
+ const auto = part.auto;
19142
+ md += `*Context compacted${auto ? " (auto)" : ""}*\n\n`;
19143
+ break;
19144
+ }
19145
+ default:
19146
+ // snapshot, patch, file, etc. - skip in both modes
19147
+ break;
19148
+ }
19149
+ return md;
19150
+ }
19151
+ // Handle /export command - export the current session to the opencode project directory
19152
+ // Usage: /export - default (text + tool calls only)
19153
+ // /export full - detailed (includes thinking, steps, costs, subtasks, etc.)
19154
+ bot.command("export", async (ctx) => {
19155
+ const chatId = ctx.chat.id.toString();
19156
+ const sessionId = chatSessions.get(chatId);
19157
+ if (!sessionId) {
19158
+ await ctx.reply("No active session. Send a message first to create one.");
19159
+ return;
19160
+ }
19161
+ const args = ctx.message.text.replace(/^\/export\s*/, "").trim().toLowerCase();
19162
+ const detailed = args === "full" || args === "detailed" || args === "all";
19163
+ try {
19164
+ const modeLabel = detailed ? "detailed" : "summary";
19165
+ const processingMsg = await ctx.reply(`Exporting session (${modeLabel})...`);
19166
+ // Fetch session info and messages in parallel
19167
+ const [sessionResult, messagesResult, pathResult] = await Promise.all([
19168
+ client.session.get({ path: { id: sessionId } }),
19169
+ client.session.messages({ path: { id: sessionId } }),
19170
+ client.path.get(),
19171
+ ]);
19172
+ if (sessionResult.error || !sessionResult.data) {
19173
+ throw new Error(`Failed to get session: ${JSON.stringify(sessionResult.error)}`);
19174
+ }
19175
+ if (messagesResult.error || !messagesResult.data) {
19176
+ throw new Error(`Failed to get messages: ${JSON.stringify(messagesResult.error)}`);
19177
+ }
19178
+ const session = sessionResult.data;
19179
+ const messages = messagesResult.data;
19180
+ // Build the markdown export
19181
+ let md = `# ${session.title}\n\n`;
19182
+ md += `**Session ID:** ${session.id}\n`;
19183
+ md += `**Created:** ${new Date(session.time.created * 1000).toLocaleString()}\n`;
19184
+ md += `**Updated:** ${new Date(session.time.updated * 1000).toLocaleString()}\n`;
19185
+ if (detailed) {
19186
+ md += `**Export mode:** Detailed\n`;
19187
+ }
19188
+ md += `\n---\n\n`;
19189
+ for (const msg of messages) {
19190
+ const info = msg.info;
19191
+ const parts = msg.parts || [];
19192
+ if (info.role === "user") {
19193
+ md += `## User\n\n`;
19194
+ for (const part of parts) {
19195
+ md += renderPart(part, detailed);
19196
+ }
19197
+ md += `---\n\n`;
19198
+ }
19199
+ else if (info.role === "assistant") {
19200
+ const assistant = info;
19201
+ const duration = assistant.time.completed
19202
+ ? ((assistant.time.completed - assistant.time.created) / 1000).toFixed(1) + "s"
19203
+ : "";
19204
+ const modelLabel = assistant.modelID || "unknown";
19205
+ const modeLabel = assistant.mode || "";
19206
+ const header = [modeLabel, modelLabel, duration].filter(Boolean).join(" · ");
19207
+ md += `## Assistant (${header})\n\n`;
19208
+ if (detailed && assistant.tokens) {
19209
+ const t = assistant.tokens;
19210
+ md += `*Tokens: ${t.input} in / ${t.output} out`;
19211
+ if (t.reasoning > 0)
19212
+ md += ` / ${t.reasoning} reasoning`;
19213
+ if (t.cache.read > 0 || t.cache.write > 0) {
19214
+ md += ` (cache: ${t.cache.read} read, ${t.cache.write} write)`;
19215
+ }
19216
+ md += `*\n`;
19217
+ if (assistant.cost && assistant.cost > 0) {
19218
+ md += `*Cost: $${assistant.cost.toFixed(4)}*\n`;
19219
+ }
19220
+ md += `\n`;
19221
+ }
19222
+ for (const part of parts) {
19223
+ md += renderPart(part, detailed);
19224
+ }
19225
+ md += `---\n\n`;
19226
+ }
19227
+ }
19228
+ // Write to the project directory
19229
+ const projectDir = pathResult.data?.directory;
19230
+ const idPrefix = sessionId.substring(0, 8);
19231
+ const suffix = detailed ? "-detailed" : "";
19232
+ const filename = `session-${idPrefix}${suffix}.md`;
19233
+ const exportPath = (0,external_node_path_.resolve)(projectDir || ".", filename);
19234
+ (0,external_node_fs_.writeFileSync)(exportPath, md, "utf-8");
19235
+ console.log(`[Telegram] Exported session ${sessionId} to ${exportPath} (${detailed ? "detailed" : "summary"})`);
19236
+ // Delete the processing message
19237
+ try {
19238
+ await bot.telegram.deleteMessage(ctx.chat.id, processingMsg.message_id);
19239
+ }
19240
+ catch {
19241
+ // Ignore if we can't delete it
19242
+ }
19243
+ // Send the file to the user
19244
+ await bot.telegram.sendDocument(ctx.chat.id, {
19245
+ source: Buffer.from(md, "utf-8"),
19246
+ filename,
19247
+ });
19248
+ await ctx.reply(`Session exported to: ${exportPath}`);
19249
+ }
19250
+ catch (err) {
19251
+ console.error("[Telegram] Error exporting session:", err);
19252
+ await handleSessionError(chatId);
19253
+ await ctx.reply("Sorry, there was an error exporting the session. Try again or use /new to start a fresh session.");
19254
+ }
19255
+ });
19025
19256
  // Catch-all for unregistered / commands - forward to OpenCode
19026
19257
  bot.on("text", async (ctx) => {
19027
19258
  const userText = ctx.message.text;
@@ -19053,6 +19284,7 @@ async function startTelegram(options) {
19053
19284
  body: {
19054
19285
  command: commandName,
19055
19286
  arguments: commandArgs,
19287
+ agent: "default",
19056
19288
  },
19057
19289
  });
19058
19290
  if (result.error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-bot",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "Telegram bot that forwards messages to an OpenCode agent",
6
6
  "main": "./dist/index.js",