opencode-telegram-bot 1.1.0 → 1.1.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.
package/README.md CHANGED
@@ -131,6 +131,26 @@ You: [tap "Bug fix"]
131
131
  Bot: Answered: Bug fix
132
132
  ```
133
133
 
134
+ ### Permission Requests
135
+
136
+ When the OpenCode agent needs permission to perform an action (e.g. edit a file, run a bash command, access an external directory), the bot forwards the request to Telegram with inline buttons:
137
+
138
+ ```
139
+ Bot: Permission: edit
140
+ `src/app.ts`
141
+ [Allow once] [Always allow]
142
+ [Reject]
143
+
144
+ You: [tap "Allow once"]
145
+ Bot: Allowed (once): edit
146
+ ```
147
+
148
+ - **Allow once** -- approve this single request
149
+ - **Always allow** -- approve and create a permanent allow rule in OpenCode's config
150
+ - **Reject** -- deny the request
151
+
152
+ If the bot restarts while a permission request is pending, it automatically re-sends the buttons on startup. Orphan permission requests (from sessions the bot doesn't know about) are automatically rejected to unblock the session.
153
+
134
154
  The bot also registers these commands in Telegram's command menu (the `/` button), plus any OpenCode server commands discovered at startup.
135
155
 
136
156
  ### Verbose Mode
@@ -280,7 +300,7 @@ You: [tap "Auth refactoring"]
280
300
  Bot: Switched to session: Auth refactoring
281
301
  ```
282
302
 
283
- 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.
303
+ The `/sessions` list shows sessions created by the Telegram bot. At the bottom of the list, a **"Show all sessions"** button lets you discover sessions created by other OpenCode clients (like the TUI via `opencode attach`). Tapping an external session adopts it and switches to it. You cannot delete the currently active session -- use `/new` first.
284
304
 
285
305
  ## Session Persistence
286
306
 
@@ -299,6 +319,7 @@ The bot is designed to survive restarts and failures:
299
319
 
300
320
  - **Drop pending updates** -- On startup, stale Telegram updates are discarded so old button clicks and messages from a previous run are not replayed.
301
321
  - **Pending question recovery** -- If the bot restarts while the agent is waiting for a question answer, the question is re-sent to Telegram on startup so the session can be unblocked.
322
+ - **Pending permission recovery** -- If the bot restarts while the agent is waiting for a permission approval, the permission request is re-sent to Telegram. Orphan requests (from unknown sessions) are automatically rejected.
302
323
  - **Global error handler** -- Unhandled errors in Telegram update handlers are logged instead of crashing the process.
303
324
  - **Handler timeout** -- Telegraf's handler timeout is set to 10 minutes (up from 90 seconds) to accommodate long-running LLM responses.
304
325
 
package/dist/app.d.ts CHANGED
@@ -26,6 +26,10 @@ interface OpencodeClientLike {
26
26
  reply: (params: any, options?: any) => Promise<any>;
27
27
  reject: (params: any, options?: any) => Promise<any>;
28
28
  };
29
+ permission: {
30
+ list: (params?: any, options?: any) => Promise<any>;
31
+ reply: (params: any, options?: any) => Promise<any>;
32
+ };
29
33
  app: {
30
34
  agents: (params?: any, options?: any) => Promise<any>;
31
35
  };
package/dist/index.js CHANGED
@@ -20054,6 +20054,8 @@ async function startTelegram(options) {
20054
20054
  const chatModelSearchResults = new Map();
20055
20055
  // Map of questionId -> pending question context (for forwarding OpenCode questions to Telegram)
20056
20056
  const pendingQuestions = new Map();
20057
+ // Map of requestId -> pending permission context (for forwarding OpenCode permission requests to Telegram)
20058
+ const pendingPermissions = new Map();
20057
20059
  /**
20058
20060
  * Save the chat-to-session mapping and known session IDs to disk.
20059
20061
  */
@@ -20686,6 +20688,42 @@ async function startTelegram(options) {
20686
20688
  }
20687
20689
  }
20688
20690
  }
20691
+ else if (ev.type === "permission.asked" && ev.properties) {
20692
+ const permSessionId = ev.properties.sessionID;
20693
+ if (permSessionId === sessionId) {
20694
+ const requestId = ev.properties.id;
20695
+ const permission = ev.properties.permission;
20696
+ const patterns = (ev.properties.patterns || []);
20697
+ if (requestId) {
20698
+ pendingPermissions.set(requestId, {
20699
+ chatId: chatId.toString(),
20700
+ sessionId,
20701
+ permission,
20702
+ patterns,
20703
+ });
20704
+ console.log(`[Telegram] Permission request ${requestId} (${permission}) for chat ${chatId}`);
20705
+ let msg = `Permission: *${permission}*`;
20706
+ if (patterns.length > 0) {
20707
+ msg += "\n`" + patterns.join("`, `") + "`";
20708
+ }
20709
+ const keyboard = [
20710
+ [
20711
+ { text: "Allow once", callback_data: `perm_once:${requestId}` },
20712
+ { text: "Always allow", callback_data: `perm_always:${requestId}` },
20713
+ ],
20714
+ [
20715
+ { text: "Reject", callback_data: `perm_reject:${requestId}` },
20716
+ ],
20717
+ ];
20718
+ await bot.telegram.sendMessage(chatId, msg, {
20719
+ parse_mode: "Markdown",
20720
+ reply_markup: {
20721
+ inline_keyboard: keyboard,
20722
+ },
20723
+ });
20724
+ }
20725
+ }
20726
+ }
20689
20727
  }
20690
20728
  }
20691
20729
  finally {
@@ -20923,7 +20961,13 @@ async function startTelegram(options) {
20923
20961
  try {
20924
20962
  const sessions = await getKnownSessions();
20925
20963
  if (sessions.length === 0) {
20926
- await ctx.reply("No sessions found.");
20964
+ await ctx.reply("No sessions found.\n\nYou can discover sessions created outside this bot:", {
20965
+ reply_markup: {
20966
+ inline_keyboard: [
20967
+ [{ text: "Show all sessions", callback_data: "sessions:all" }],
20968
+ ],
20969
+ },
20970
+ });
20927
20971
  return;
20928
20972
  }
20929
20973
  const activeSession = sessions.find((session) => session.id === activeSessionId);
@@ -20932,25 +20976,33 @@ async function startTelegram(options) {
20932
20976
  msgLines.push(`Current session: ${activeSession.title}`);
20933
20977
  }
20934
20978
  const otherSessions = sessions.filter((session) => session.id !== activeSessionId);
20935
- if (otherSessions.length === 0) {
20979
+ const keyboard = [];
20980
+ if (otherSessions.length > 0) {
20981
+ msgLines.push("Tap a session to switch or delete.");
20982
+ otherSessions.forEach((session) => {
20983
+ keyboard.push([
20984
+ {
20985
+ text: session.title,
20986
+ callback_data: `switch:${session.id}`,
20987
+ },
20988
+ {
20989
+ text: "Delete",
20990
+ callback_data: `delete:${session.id}`,
20991
+ },
20992
+ ]);
20993
+ });
20994
+ }
20995
+ else {
20936
20996
  msgLines.push("This is your only session.");
20937
- await ctx.reply(msgLines.join("\n"));
20938
- return;
20939
20997
  }
20940
- msgLines.push("Tap a session to switch or delete.");
20941
- const keyboard = [];
20942
- otherSessions.forEach((session) => {
20943
- keyboard.push([
20944
- {
20945
- text: session.title,
20946
- callback_data: `switch:${session.id}`,
20947
- },
20948
- {
20949
- text: "Delete",
20950
- callback_data: `delete:${session.id}`,
20951
- },
20952
- ]);
20953
- });
20998
+ // Always show "Show all sessions" button to discover sessions
20999
+ // created outside the Telegram bot (e.g. via opencode TUI)
21000
+ keyboard.push([
21001
+ {
21002
+ text: "Show all sessions",
21003
+ callback_data: "sessions:all",
21004
+ },
21005
+ ]);
20954
21006
  await ctx.reply(msgLines.join("\n"), {
20955
21007
  reply_markup: {
20956
21008
  inline_keyboard: keyboard,
@@ -21110,6 +21162,83 @@ async function startTelegram(options) {
21110
21162
  await answerAndEdit(ctx, "Failed to delete session.");
21111
21163
  }
21112
21164
  });
21165
+ // Handle "Show all sessions" button - list sessions not tracked by the bot
21166
+ bot.action("sessions:all", async (ctx) => {
21167
+ const chatId = getChatIdFromContext(ctx);
21168
+ if (!chatId)
21169
+ return;
21170
+ try {
21171
+ // Delete the button message
21172
+ try {
21173
+ await ctx.deleteMessage();
21174
+ }
21175
+ catch {
21176
+ // Ignore if already deleted
21177
+ }
21178
+ await ctx.answerCbQuery();
21179
+ const list = await client.session.list({});
21180
+ const allSessions = (list.data || [])
21181
+ .filter((s) => !knownSessionIds.has(s.id) && !s.parentID)
21182
+ .sort((a, b) => b.time.updated - a.time.updated);
21183
+ if (allSessions.length === 0) {
21184
+ await ctx.reply("No other sessions found.");
21185
+ return;
21186
+ }
21187
+ const keyboard = [];
21188
+ allSessions.forEach((session) => {
21189
+ keyboard.push([
21190
+ {
21191
+ text: session.title,
21192
+ callback_data: `adopt:${session.id}`,
21193
+ },
21194
+ ]);
21195
+ });
21196
+ await ctx.reply("Sessions created outside this bot.\nTap to adopt and switch:", {
21197
+ reply_markup: {
21198
+ inline_keyboard: keyboard,
21199
+ },
21200
+ });
21201
+ }
21202
+ catch (err) {
21203
+ console.error("[Telegram] Error listing all sessions:", err);
21204
+ await ctx.reply("Failed to list sessions.");
21205
+ }
21206
+ });
21207
+ // Handle adopt button - adopt an external session and switch to it
21208
+ bot.action(/^adopt:(.+)$/, async (ctx) => {
21209
+ const chatId = getChatIdFromContext(ctx);
21210
+ const sessionId = ctx.match?.[1];
21211
+ if (!chatId || !sessionId)
21212
+ return;
21213
+ try {
21214
+ // Verify the session still exists on the server
21215
+ const list = await client.session.list({});
21216
+ const allSessions = (list.data || []);
21217
+ const match = allSessions.find((s) => s.id === sessionId);
21218
+ if (!match) {
21219
+ await answerAndEdit(ctx, "Session not found. It may have been deleted.");
21220
+ return;
21221
+ }
21222
+ // Delete the button message
21223
+ try {
21224
+ await ctx.deleteMessage();
21225
+ }
21226
+ catch {
21227
+ // Ignore if already deleted
21228
+ }
21229
+ await ctx.answerCbQuery();
21230
+ // Adopt: add to known sessions and switch to it
21231
+ knownSessionIds.add(match.id);
21232
+ chatSessions.set(chatId, match.id);
21233
+ saveSessions();
21234
+ console.log(`[Telegram] Adopted and switched chat ${chatId} to session ${match.id}`);
21235
+ await ctx.reply(`Adopted and switched to session: ${match.title}`);
21236
+ }
21237
+ catch (err) {
21238
+ console.error("[Telegram] Error adopting session:", err);
21239
+ await answerAndEdit(ctx, "Failed to adopt session.");
21240
+ }
21241
+ });
21113
21242
  // Handle /verbose command - toggle verbose mode for this chat
21114
21243
  // Usage: /verbose, /verbose on, /verbose off
21115
21244
  bot.command("verbose", (ctx) => {
@@ -21454,6 +21583,36 @@ async function startTelegram(options) {
21454
21583
  await answerAndEdit(ctx, "Failed to dismiss question.");
21455
21584
  }
21456
21585
  });
21586
+ // Handle permission reply callbacks (from OpenCode permission.asked events)
21587
+ bot.action(/^perm_(once|always|reject):(.+)$/, async (ctx) => {
21588
+ const action = ctx.match?.[1];
21589
+ const requestId = ctx.match?.[2];
21590
+ if (!action || !requestId)
21591
+ return;
21592
+ console.log(`[Telegram] Permission ${action} callback for ${requestId}`);
21593
+ const pending = pendingPermissions.get(requestId);
21594
+ if (!pending) {
21595
+ await answerAndEdit(ctx, "This permission request has expired or was already answered.");
21596
+ return;
21597
+ }
21598
+ try {
21599
+ await client.permission.reply({
21600
+ requestID: requestId,
21601
+ reply: action,
21602
+ });
21603
+ pendingPermissions.delete(requestId);
21604
+ const labels = {
21605
+ once: "Allowed (once)",
21606
+ always: "Always allowed",
21607
+ reject: "Rejected",
21608
+ };
21609
+ await answerAndEdit(ctx, `${labels[action]}: ${pending.permission}`);
21610
+ }
21611
+ catch (err) {
21612
+ console.error("[Telegram] Error replying to permission:", err);
21613
+ await answerAndEdit(ctx, "Failed to respond to permission request.");
21614
+ }
21615
+ });
21457
21616
  // Handle /usage command - show token and cost usage for current session
21458
21617
  bot.command("usage", async (ctx) => {
21459
21618
  const chatId = ctx.chat.id.toString();
@@ -21885,6 +22044,65 @@ async function startTelegram(options) {
21885
22044
  catch (err) {
21886
22045
  console.warn("[Telegram] Failed to check for pending questions:", err);
21887
22046
  }
22047
+ // Check for pending permission requests left over from previous bot runs.
22048
+ try {
22049
+ const pendingPermResult = await client.permission.list({});
22050
+ if (pendingPermResult.data && pendingPermResult.data.length > 0) {
22051
+ console.log(`[Telegram] Found ${pendingPermResult.data.length} pending permission(s) from previous run`);
22052
+ for (const pp of pendingPermResult.data) {
22053
+ const requestId = pp.id;
22054
+ const permSessionId = pp.sessionID;
22055
+ const permission = pp.permission;
22056
+ const patterns = (pp.patterns || []);
22057
+ const chatId = sessionToChatId(permSessionId);
22058
+ if (!chatId || !requestId) {
22059
+ // No matching chat — reject the stale permission to unblock the session
22060
+ console.log(`[Telegram] Rejecting orphan pending permission ${requestId} (no matching chat)`);
22061
+ try {
22062
+ await client.permission.reply({ requestID: requestId, reply: "reject" });
22063
+ }
22064
+ catch (err) {
22065
+ console.warn(`[Telegram] Failed to reject orphan permission ${requestId}:`, err);
22066
+ }
22067
+ continue;
22068
+ }
22069
+ pendingPermissions.set(requestId, {
22070
+ chatId,
22071
+ sessionId: permSessionId,
22072
+ permission,
22073
+ patterns,
22074
+ });
22075
+ const numericChatId = Number(chatId);
22076
+ let msg = `Permission: *${permission}*`;
22077
+ if (patterns.length > 0) {
22078
+ msg += "\n`" + patterns.join("`, `") + "`";
22079
+ }
22080
+ msg += "\n_(resumed from previous session)_";
22081
+ const keyboard = [
22082
+ [
22083
+ { text: "Allow once", callback_data: `perm_once:${requestId}` },
22084
+ { text: "Always allow", callback_data: `perm_always:${requestId}` },
22085
+ ],
22086
+ [
22087
+ { text: "Reject", callback_data: `perm_reject:${requestId}` },
22088
+ ],
22089
+ ];
22090
+ try {
22091
+ await bot.telegram.sendMessage(numericChatId, msg, {
22092
+ parse_mode: "Markdown",
22093
+ reply_markup: { inline_keyboard: keyboard },
22094
+ });
22095
+ }
22096
+ catch (err) {
22097
+ console.warn(`[Telegram] Failed to re-send permission to chat ${chatId}:`, err);
22098
+ }
22099
+ console.log(`[Telegram] Re-forwarded pending permission ${requestId} to chat ${chatId}`);
22100
+ }
22101
+ }
22102
+ }
22103
+ catch (err) {
22104
+ console.warn("[Telegram] Failed to check for pending permissions:", err);
22105
+ }
21888
22106
  if (options.launch !== false) {
21889
22107
  try {
21890
22108
  // Start the bot — launch() returns a promise that resolves only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-bot",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "description": "Telegram bot that forwards messages to an OpenCode agent",
6
6
  "main": "./dist/index.js",