@tormentalabs/opencode-telegram-plugin 0.2.0 → 0.3.0

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
@@ -7,9 +7,12 @@ Telegram bot plugin for [OpenCode](https://opencode.ai) — remote control and i
7
7
  - **Remote Control** — attach to your active TUI session, see streaming responses in real-time, send prompts, and approve/deny permission requests via inline buttons
8
8
  - **Independent Sessions** — create standalone sessions for async work from your phone
9
9
  - **Live Streaming** — AI responses stream into Telegram with throttled in-place message edits
10
- - **Permission Handling** — tool permission prompts appear as inline keyboards (Approve / Deny)
10
+ - **Permission Handling** — tool permission prompts with inline keyboards (Approve / Always / Deny), plus text replies (YES/NO/ALWAYS)
11
+ - **Shell Access** — run shell commands directly from Telegram (`!git status` or `/shell`)
11
12
  - **Tool Status** — see which tools are executing in real-time
12
13
  - **Multi-session** — switch between sessions, list active sessions, create new ones
14
+ - **Session Control** — undo/redo file changes, compact/summarize, share sessions, view diffs
15
+ - **Bot Menu** — all commands auto-registered in Telegram's command menu, including auto-discovered OpenCode commands
13
16
  - **Config Management** — built-in `/telegram` slash command for setup without leaving OpenCode
14
17
 
15
18
  ## Quick Start
@@ -142,19 +145,30 @@ Once the bot is running, these commands are available in Telegram:
142
145
  | Command | Description |
143
146
  |---------|-------------|
144
147
  | `/start` | Initialize bot, auto-attach to active session |
145
- | `/attach` | Attach to an active TUI session (shows picker) |
148
+ | `/help` | Show help |
149
+ | `/attach [id]` | Attach to a session (shows picker if no ID) |
146
150
  | `/detach` | Detach from the current session |
147
- | `/new` | Create an independent session |
151
+ | `/new [title]` | Create an independent session |
148
152
  | `/sessions` | List all sessions |
149
- | `/switch` | Switch to a different session |
150
- | `/model` | List available models with favorites marked |
153
+ | `/switch [id]` | Switch to a different session |
154
+ | `!command` | Run a shell command (e.g. `!git status`) |
155
+ | `/shell <cmd>` | Run a shell command |
156
+ | `/diff` | Show changed files in current session |
157
+ | `/messages [n]` | Show last N messages (default: 5, max: 20) |
158
+ | `/pending` | List pending permission requests |
159
+ | `/model` | List available models with favorites |
151
160
  | `/model <provider/model-id>` | Set a specific model for this chat |
152
161
  | `/model reset` | Reset to the default model |
153
- | `/effort` | Show current effort level |
154
- | `/effort <low\|medium\|high>` | Set reasoning effort level |
162
+ | `/effort [low\|medium\|high]` | Set/show reasoning effort level |
155
163
  | `/status` | Show current connection status |
156
- | `/abort` | Abort the current session |
157
- | `/help` | Show help |
164
+ | `/abort` | Abort the current operation |
165
+ | `/oc_undo` | Undo last message and file changes |
166
+ | `/oc_redo` | Redo undone changes |
167
+ | `/oc_compact` | Summarize/compact the session |
168
+ | `/oc_share` | Share the session (get URL) |
169
+ | `/commands` | List all available OpenCode commands |
170
+
171
+ Text replies to permission messages: `YES`, `NO`, or `ALWAYS` (reply to a specific permission message, or send bare to apply to the most recent).
158
172
 
159
173
  ## How It Works
160
174
 
@@ -184,10 +198,15 @@ When OpenCode requests tool permissions, an inline keyboard appears in Telegram:
184
198
  ```
185
199
  🔐 Permission requested: bash
186
200
  Command: git status
187
- [✅ Approve] [❌ Deny]
201
+ [✅ Approve] [✅ Always] [❌ Deny]
188
202
  ```
189
203
 
190
- Tapping a button responds to the permission request in OpenCode.
204
+ You can respond by:
205
+ - Tapping an inline button
206
+ - Replying with `YES`, `ALWAYS`, or `NO` to the permission message
207
+ - Sending bare `YES`/`ALWAYS`/`NO` to apply to the most recent pending request
208
+
209
+ Use `/pending` to see all pending permission requests.
191
210
 
192
211
  ## Project Structure
193
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tormentalabs/opencode-telegram-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Telegram bot plugin for OpenCode — remote control and independent sessions from your phone",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/bot.ts CHANGED
@@ -13,6 +13,17 @@ import {
13
13
  effortCommand,
14
14
  statusCommand,
15
15
  abortCommand,
16
+ shellCommand,
17
+ diffCommand,
18
+ pendingCommand,
19
+ messagesCommand,
20
+ ocUndoCommand,
21
+ ocRedoCommand,
22
+ ocCompactCommand,
23
+ ocShareCommand,
24
+ commandsCommand,
25
+ ocGenericCommand,
26
+ discoverCommands,
16
27
  setClient as setCommandsClient,
17
28
  } from "./handlers/commands.js";
18
29
  import {
@@ -91,6 +102,26 @@ export function createBot(opts: CreateBotOptions): Bot {
91
102
  bot.command("effort", effortCommand);
92
103
  bot.command("status", statusCommand);
93
104
  bot.command("abort", abortCommand);
105
+ bot.command("shell", shellCommand);
106
+ bot.command("diff", diffCommand);
107
+ bot.command("pending", pendingCommand);
108
+ bot.command("messages", messagesCommand);
109
+ bot.command("oc_undo", ocUndoCommand);
110
+ bot.command("oc_redo", ocRedoCommand);
111
+ bot.command("oc_compact", ocCompactCommand);
112
+ bot.command("oc_share", ocShareCommand);
113
+ bot.command("commands", commandsCommand);
114
+
115
+ // ── Dynamic /oc_* catch-all for custom OpenCode commands ──────────────
116
+ bot.hears(/^\/oc_(\w+)(?:\s+(.*))?$/, async (ctx) => {
117
+ const match = ctx.match;
118
+ const commandName = match[1]!;
119
+ // Skip commands we handle explicitly
120
+ if (["undo", "redo", "compact", "share"].includes(commandName)) return;
121
+ // Inject the arguments as ctx.match for ocGenericCommand
122
+ (ctx as any).match = match[2] ?? "";
123
+ await ocGenericCommand(commandName, ctx);
124
+ });
94
125
 
95
126
  // ── Callback queries ──────────────────────────────────────────────────
96
127
  bot.on("callback_query:data", handleCallback);
@@ -123,6 +154,63 @@ export function injectClient(client: unknown): void {
123
154
  setCallbacksClient(client);
124
155
  }
125
156
 
157
+ // ---------------------------------------------------------------------------
158
+ // Bot menu registration
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Register all bot commands in Telegram's command menu.
163
+ * Also auto-discovers custom OpenCode commands and registers them as /oc_*.
164
+ */
165
+ export async function registerBotMenu(bot: Bot): Promise<void> {
166
+ const builtinCommands = [
167
+ { command: "start", description: "Start the bot" },
168
+ { command: "help", description: "Show help" },
169
+ { command: "attach", description: "Attach to a session" },
170
+ { command: "detach", description: "Detach from session" },
171
+ { command: "new", description: "Create new session" },
172
+ { command: "sessions", description: "List sessions" },
173
+ { command: "switch", description: "Switch session" },
174
+ { command: "model", description: "List/set model" },
175
+ { command: "effort", description: "Set reasoning effort" },
176
+ { command: "status", description: "Show bot status" },
177
+ { command: "abort", description: "Abort current operation" },
178
+ { command: "shell", description: "Run shell command" },
179
+ { command: "diff", description: "Show changed files" },
180
+ { command: "pending", description: "List pending permissions" },
181
+ { command: "messages", description: "Show recent messages" },
182
+ { command: "oc_undo", description: "Undo last changes" },
183
+ { command: "oc_redo", description: "Redo undone changes" },
184
+ { command: "oc_compact", description: "Compact/summarize session" },
185
+ { command: "oc_share", description: "Share session URL" },
186
+ { command: "commands", description: "List OpenCode commands" },
187
+ ];
188
+
189
+ // Auto-discover custom OpenCode commands
190
+ try {
191
+ const ocCommands = await discoverCommands();
192
+ const builtinNames = new Set(["undo", "redo", "compact", "share"]);
193
+ for (const cmd of ocCommands) {
194
+ if (builtinNames.has(cmd.name)) continue; // already registered explicitly
195
+ builtinCommands.push({
196
+ command: `oc_${cmd.name}`,
197
+ description: cmd.description?.slice(0, 256) ?? `OpenCode: ${cmd.name}`,
198
+ });
199
+ }
200
+ } catch {
201
+ // Non-fatal — proceed with builtin commands only
202
+ }
203
+
204
+ // Telegram limits to 100 commands
205
+ const commands = builtinCommands.slice(0, 100);
206
+
207
+ try {
208
+ await bot.api.setMyCommands(commands);
209
+ } catch {
210
+ // Non-fatal — menu registration failure shouldn't block startup
211
+ }
212
+ }
213
+
126
214
  // ---------------------------------------------------------------------------
127
215
  // Helpers
128
216
  // ---------------------------------------------------------------------------
@@ -9,9 +9,9 @@ import { escapeHtml } from "../utils/format.js";
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
11
  interface OpenCodeClient {
12
- postSessionByIdPermissionsByPermissionId(params: {
13
- path: { id: string; permissionId: string };
14
- body: unknown;
12
+ postSessionIdPermissionsPermissionId(params: {
13
+ path: { id: string; permissionID: string };
14
+ body: { response: "once" | "always" | "reject" };
15
15
  }): Promise<unknown>;
16
16
  }
17
17
 
@@ -91,7 +91,7 @@ export async function handleCallback(ctx: Context): Promise<void> {
91
91
  try {
92
92
  switch (entry.action) {
93
93
  // ----------------------------------------------------------------
94
- // Permission approval
94
+ // Permission approval (once)
95
95
  // ----------------------------------------------------------------
96
96
  case "perm_approve": {
97
97
  const { sessionId, permissionId } = entry.data;
@@ -102,9 +102,9 @@ export async function handleCallback(ctx: Context): Promise<void> {
102
102
  return;
103
103
  }
104
104
 
105
- await getClient().postSessionByIdPermissionsByPermissionId({
106
- path: { id: sessionId, permissionId },
107
- body: {},
105
+ await getClient().postSessionIdPermissionsPermissionId({
106
+ path: { id: sessionId, permissionID: permissionId },
107
+ body: { response: "once" },
108
108
  });
109
109
 
110
110
  getChatState(chatId).pendingPermissions.delete(permissionId);
@@ -117,7 +117,33 @@ export async function handleCallback(ctx: Context): Promise<void> {
117
117
  }
118
118
 
119
119
  // ----------------------------------------------------------------
120
- // Permission denialnotify SDK so the session doesn't hang
120
+ // Permission approval (always) permanently whitelist
121
+ // ----------------------------------------------------------------
122
+ case "perm_always": {
123
+ const { sessionId, permissionId } = entry.data;
124
+ if (!sessionId || !permissionId) {
125
+ await safeSend(() =>
126
+ ctx.answerCallbackQuery({ text: "Invalid callback data." }),
127
+ );
128
+ return;
129
+ }
130
+
131
+ await getClient().postSessionIdPermissionsPermissionId({
132
+ path: { id: sessionId, permissionID: permissionId },
133
+ body: { response: "always" },
134
+ });
135
+
136
+ getChatState(chatId).pendingPermissions.delete(permissionId);
137
+ await safeSend(() => ctx.answerCallbackQuery({ text: "✅ Always Allowed" }));
138
+ await safeEditText(
139
+ ctx,
140
+ `${escapeHtml(originalMessageText(ctx))}\n\n✅ <b>Always Allowed</b>`,
141
+ );
142
+ break;
143
+ }
144
+
145
+ // ----------------------------------------------------------------
146
+ // Permission denial
121
147
  // ----------------------------------------------------------------
122
148
  case "perm_deny": {
123
149
  const { sessionId, permissionId } = entry.data;
@@ -128,13 +154,10 @@ export async function handleCallback(ctx: Context): Promise<void> {
128
154
  return;
129
155
  }
130
156
 
131
- // Best-effort: call the permission endpoint to unblock the session.
132
- // If the SDK has no explicit deny mechanism, this call may fail —
133
- // the session will eventually time out on its own.
134
157
  try {
135
- await getClient().postSessionByIdPermissionsByPermissionId({
136
- path: { id: sessionId, permissionId },
137
- body: { deny: true },
158
+ await getClient().postSessionIdPermissionsPermissionId({
159
+ path: { id: sessionId, permissionID: permissionId },
160
+ body: { response: "reject" },
138
161
  });
139
162
  } catch {
140
163
  // SDK may not support explicit deny — fall through silently
@@ -22,11 +22,55 @@ interface SessionSummary {
22
22
  createdAt: string;
23
23
  }
24
24
 
25
+ interface FileDiff {
26
+ file: string;
27
+ additions: number;
28
+ deletions: number;
29
+ status?: "added" | "deleted" | "modified";
30
+ }
31
+
32
+ interface MessageInfo {
33
+ id: string;
34
+ role: string;
35
+ createdAt?: string;
36
+ }
37
+
38
+ interface MessagePart {
39
+ type: string;
40
+ text?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ interface SessionData {
45
+ id: string;
46
+ title?: string;
47
+ share?: { url: string };
48
+ }
49
+
50
+ interface AssistantMessage {
51
+ parts?: MessagePart[];
52
+ }
53
+
54
+ interface OpenCodeCommand {
55
+ name: string;
56
+ description?: string;
57
+ source?: string;
58
+ }
59
+
25
60
  interface OpenCodeClient {
26
61
  session: {
27
62
  list(): Promise<{ data: SessionSummary[] }>;
28
63
  create(params: { body: { title: string } }): Promise<{ data: { id: string } }>;
29
64
  abort(params: { path: { id: string } }): Promise<boolean>;
65
+ shell(params: { path: { id: string }; body: { agent: string; command: string } }): Promise<{ data: AssistantMessage }>;
66
+ diff(params: { path: { id: string }; query?: { messageID?: string } }): Promise<{ data: FileDiff[] }>;
67
+ share(params: { path: { id: string } }): Promise<{ data: SessionData }>;
68
+ unshare(params: { path: { id: string } }): Promise<{ data: SessionData }>;
69
+ revert(params: { path: { id: string }; body?: { messageID: string } }): Promise<{ data: SessionData }>;
70
+ unrevert(params: { path: { id: string } }): Promise<{ data: SessionData }>;
71
+ summarize(params: { path: { id: string }; body?: { providerID: string; modelID: string } }): Promise<{ data: boolean }>;
72
+ messages(params: { path: { id: string }; query?: { limit?: number } }): Promise<{ data: Array<{ info: MessageInfo; parts: MessagePart[] }> }>;
73
+ command(params: { path: { id: string }; body: { command: string; arguments: string } }): Promise<{ data: unknown }>;
30
74
  };
31
75
  config: {
32
76
  providers(): Promise<{
@@ -39,6 +83,9 @@ interface OpenCodeClient {
39
83
  };
40
84
  }>;
41
85
  };
86
+ command: {
87
+ list(): Promise<{ data: OpenCodeCommand[] }>;
88
+ };
42
89
  }
43
90
 
44
91
  let _client: OpenCodeClient | null = null;
@@ -68,7 +115,11 @@ const HELP_TEXT = `
68
115
 
69
116
  <b>While in a Session</b>
70
117
  Just send a message to prompt OpenCode
118
+ <code>!command</code> — Run a shell command (e.g. <code>!git status</code>)
119
+ /shell &lt;cmd&gt; — Run a shell command
71
120
  /abort — Abort the current running operation
121
+ /diff — Show changed files in current session
122
+ /messages — Show last 5 messages from session
72
123
 
73
124
  <b>Model &amp; Config</b>
74
125
  /model — List available models
@@ -77,6 +128,17 @@ Just send a message to prompt OpenCode
77
128
  /effort [low|medium|high] — Set reasoning effort (default: high)
78
129
  /status — Show current bot status
79
130
  /help — Show this help message
131
+
132
+ <b>Permissions</b>
133
+ Reply <code>YES</code>, <code>NO</code>, or <code>ALWAYS</code> to a permission message
134
+ /pending — List pending permission requests
135
+
136
+ <b>OpenCode Commands</b>
137
+ /oc_undo — Undo last message + file changes
138
+ /oc_redo — Redo undone changes
139
+ /oc_compact — Summarize/compact the session
140
+ /oc_share — Share the session (get URL)
141
+ /commands — List all available OpenCode commands
80
142
  `.trim();
81
143
 
82
144
  /**
@@ -560,3 +622,412 @@ export async function abortCommand(ctx: Context): Promise<void> {
560
622
  );
561
623
  }
562
624
  }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // Shell command — /shell <cmd> or !<cmd>
628
+ // ---------------------------------------------------------------------------
629
+
630
+ export async function shellCommand(ctx: Context): Promise<void> {
631
+ const chatId = ctx.chat?.id;
632
+ if (!chatId) return;
633
+
634
+ const command = typeof ctx.match === "string" ? ctx.match.trim() : "";
635
+ if (!command) {
636
+ await safeSend(() =>
637
+ ctx.reply("Usage: <code>/shell command</code>\nExample: <code>/shell git status</code>\n\nOr use the <code>!</code> prefix: <code>!git status</code>", { parse_mode: "HTML" }),
638
+ );
639
+ return;
640
+ }
641
+
642
+ await executeShell(ctx, chatId, command);
643
+ }
644
+
645
+ /**
646
+ * Execute a shell command in the current session and send the result.
647
+ * Shared between /shell and !<cmd>.
648
+ */
649
+ export async function executeShell(ctx: Context, chatId: number, command: string): Promise<void> {
650
+ const sessionId = getActiveSessionId(chatId);
651
+ if (!sessionId) {
652
+ await safeSend(() =>
653
+ ctx.reply("No active session. Use /attach or /new first."),
654
+ );
655
+ return;
656
+ }
657
+
658
+ try {
659
+ await ctx.api.sendChatAction(chatId, "typing");
660
+ } catch { /* non-fatal */ }
661
+
662
+ try {
663
+ const { data } = await getClient().session.shell({
664
+ path: { id: sessionId },
665
+ body: { agent: "", command },
666
+ });
667
+
668
+ const parts = data?.parts ?? [];
669
+ const textParts = parts
670
+ .filter((p) => p.type === "text" && p.text)
671
+ .map((p) => p.text!);
672
+
673
+ const output = textParts.join("\n").trim();
674
+
675
+ if (!output) {
676
+ await safeSend(() =>
677
+ ctx.reply(`<code>$ ${escapeHtml(command)}</code>\n<i>(no output)</i>`, { parse_mode: "HTML" }),
678
+ );
679
+ return;
680
+ }
681
+
682
+ // Chunk output if needed (4000 char limit for safety)
683
+ const header = `<code>$ ${escapeHtml(command)}</code>\n`;
684
+ const MAX_LEN = 4000 - header.length;
685
+
686
+ if (output.length <= MAX_LEN) {
687
+ await safeSend(() =>
688
+ ctx.reply(`${header}<pre>${escapeHtml(output)}</pre>`, { parse_mode: "HTML" }),
689
+ );
690
+ } else {
691
+ // Send header first, then chunks
692
+ await safeSend(() =>
693
+ ctx.reply(header, { parse_mode: "HTML" }),
694
+ );
695
+
696
+ let remaining = output;
697
+ while (remaining.length > 0) {
698
+ const chunk = remaining.slice(0, 4000);
699
+ remaining = remaining.slice(4000);
700
+ await safeSend(() =>
701
+ ctx.reply(`<pre>${escapeHtml(chunk)}</pre>`, { parse_mode: "HTML" }),
702
+ );
703
+ }
704
+ }
705
+ } catch (err) {
706
+ const msg = err instanceof Error ? err.message : String(err);
707
+ await safeSend(() =>
708
+ ctx.reply(`❌ Shell error: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
709
+ );
710
+ }
711
+ }
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // /diff — Show changed files
715
+ // ---------------------------------------------------------------------------
716
+
717
+ export async function diffCommand(ctx: Context): Promise<void> {
718
+ const chatId = ctx.chat?.id;
719
+ if (!chatId) return;
720
+
721
+ const sessionId = getActiveSessionId(chatId);
722
+ if (!sessionId) {
723
+ await safeSend(() => ctx.reply("No active session. Use /attach or /new first."));
724
+ return;
725
+ }
726
+
727
+ try {
728
+ const { data: diffs } = await getClient().session.diff({
729
+ path: { id: sessionId },
730
+ });
731
+
732
+ if (!diffs || diffs.length === 0) {
733
+ await safeSend(() => ctx.reply("No file changes in this session."));
734
+ return;
735
+ }
736
+
737
+ const statusIcon: Record<string, string> = {
738
+ added: "🟢",
739
+ deleted: "🔴",
740
+ modified: "🟡",
741
+ };
742
+
743
+ const lines = diffs.map((d) => {
744
+ const icon = statusIcon[d.status ?? "modified"] ?? "🟡";
745
+ const stats = `<code>+${d.additions} -${d.deletions}</code>`;
746
+ return `${icon} ${stats} ${escapeHtml(d.file)}`;
747
+ });
748
+
749
+ const totalAdd = diffs.reduce((s, d) => s + d.additions, 0);
750
+ const totalDel = diffs.reduce((s, d) => s + d.deletions, 0);
751
+ const summary = `\n<b>${diffs.length} file${diffs.length === 1 ? "" : "s"}</b> changed: <code>+${totalAdd} -${totalDel}</code>`;
752
+
753
+ await safeSend(() =>
754
+ ctx.reply(`<b>Changed Files:</b>\n\n${lines.join("\n")}${summary}`, { parse_mode: "HTML" }),
755
+ );
756
+ } catch (err) {
757
+ const msg = err instanceof Error ? err.message : String(err);
758
+ await safeSend(() =>
759
+ ctx.reply(`❌ Failed to get diff: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
760
+ );
761
+ }
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // /pending — List pending permission requests
766
+ // ---------------------------------------------------------------------------
767
+
768
+ export async function pendingCommand(ctx: Context): Promise<void> {
769
+ const chatId = ctx.chat?.id;
770
+ if (!chatId) return;
771
+
772
+ const state = getChatState(chatId);
773
+ const pending = Array.from(state.pendingPermissions.values());
774
+
775
+ if (pending.length === 0) {
776
+ await safeSend(() => ctx.reply("No pending permission requests."));
777
+ return;
778
+ }
779
+
780
+ const lines = pending.map((p, i) => {
781
+ const age = Math.round((Date.now() - p.timestamp) / 1000);
782
+ const ageStr = age < 60 ? `${age}s ago` : `${Math.round(age / 60)}m ago`;
783
+ return `${i + 1}. <b>${escapeHtml(p.tool)}</b> (${ageStr})\n ${escapeHtml(p.description.slice(0, 100))}`;
784
+ });
785
+
786
+ await safeSend(() =>
787
+ ctx.reply(
788
+ `<b>Pending Permissions (${pending.length}):</b>\n\n${lines.join("\n\n")}\n\nReply <code>YES</code>, <code>NO</code>, or <code>ALWAYS</code> to the most recent, or tap the inline buttons.`,
789
+ { parse_mode: "HTML" },
790
+ ),
791
+ );
792
+ }
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // /messages — Show last N messages from the session
796
+ // ---------------------------------------------------------------------------
797
+
798
+ export async function messagesCommand(ctx: Context): Promise<void> {
799
+ const chatId = ctx.chat?.id;
800
+ if (!chatId) return;
801
+
802
+ const sessionId = getActiveSessionId(chatId);
803
+ if (!sessionId) {
804
+ await safeSend(() => ctx.reply("No active session. Use /attach or /new first."));
805
+ return;
806
+ }
807
+
808
+ const limitArg = typeof ctx.match === "string" ? parseInt(ctx.match.trim(), 10) : NaN;
809
+ const limit = Number.isFinite(limitArg) && limitArg > 0 ? Math.min(limitArg, 20) : 5;
810
+
811
+ try {
812
+ const { data: messages } = await getClient().session.messages({
813
+ path: { id: sessionId },
814
+ query: { limit },
815
+ });
816
+
817
+ if (!messages || messages.length === 0) {
818
+ await safeSend(() => ctx.reply("No messages in this session."));
819
+ return;
820
+ }
821
+
822
+ const lines = messages.map((m) => {
823
+ const role = m.info.role === "user" ? "👤" : "🤖";
824
+ const textParts = m.parts
825
+ .filter((p) => p.type === "text" && p.text)
826
+ .map((p) => p.text!);
827
+ const preview = textParts.join(" ").slice(0, 200);
828
+ return `${role} <b>${escapeHtml(m.info.role)}</b>\n${escapeHtml(preview)}${preview.length >= 200 ? "…" : ""}`;
829
+ });
830
+
831
+ await safeSend(() =>
832
+ ctx.reply(
833
+ `<b>Last ${messages.length} message${messages.length === 1 ? "" : "s"}:</b>\n\n${lines.join("\n\n")}`,
834
+ { parse_mode: "HTML" },
835
+ ),
836
+ );
837
+ } catch (err) {
838
+ const msg = err instanceof Error ? err.message : String(err);
839
+ await safeSend(() =>
840
+ ctx.reply(`❌ Failed to fetch messages: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
841
+ );
842
+ }
843
+ }
844
+
845
+ // ---------------------------------------------------------------------------
846
+ // /oc_undo — Revert last message + file changes
847
+ // ---------------------------------------------------------------------------
848
+
849
+ export async function ocUndoCommand(ctx: Context): Promise<void> {
850
+ const chatId = ctx.chat?.id;
851
+ if (!chatId) return;
852
+
853
+ const sessionId = getActiveSessionId(chatId);
854
+ if (!sessionId) {
855
+ await safeSend(() => ctx.reply("No active session."));
856
+ return;
857
+ }
858
+
859
+ try {
860
+ await getClient().session.revert({
861
+ path: { id: sessionId },
862
+ });
863
+ await safeSend(() => ctx.reply("↩️ Undone. File changes reverted."));
864
+ } catch (err) {
865
+ const msg = err instanceof Error ? err.message : String(err);
866
+ await safeSend(() =>
867
+ ctx.reply(`❌ Undo failed: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
868
+ );
869
+ }
870
+ }
871
+
872
+ // ---------------------------------------------------------------------------
873
+ // /oc_redo — Restore undone changes
874
+ // ---------------------------------------------------------------------------
875
+
876
+ export async function ocRedoCommand(ctx: Context): Promise<void> {
877
+ const chatId = ctx.chat?.id;
878
+ if (!chatId) return;
879
+
880
+ const sessionId = getActiveSessionId(chatId);
881
+ if (!sessionId) {
882
+ await safeSend(() => ctx.reply("No active session."));
883
+ return;
884
+ }
885
+
886
+ try {
887
+ await getClient().session.unrevert({
888
+ path: { id: sessionId },
889
+ });
890
+ await safeSend(() => ctx.reply("↪️ Redone. Changes restored."));
891
+ } catch (err) {
892
+ const msg = err instanceof Error ? err.message : String(err);
893
+ await safeSend(() =>
894
+ ctx.reply(`❌ Redo failed: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
895
+ );
896
+ }
897
+ }
898
+
899
+ // ---------------------------------------------------------------------------
900
+ // /oc_compact — Summarize/compact session
901
+ // ---------------------------------------------------------------------------
902
+
903
+ export async function ocCompactCommand(ctx: Context): Promise<void> {
904
+ const chatId = ctx.chat?.id;
905
+ if (!chatId) return;
906
+
907
+ const sessionId = getActiveSessionId(chatId);
908
+ if (!sessionId) {
909
+ await safeSend(() => ctx.reply("No active session."));
910
+ return;
911
+ }
912
+
913
+ try {
914
+ await getClient().session.summarize({
915
+ path: { id: sessionId },
916
+ });
917
+ await safeSend(() => ctx.reply("📦 Session compacted."));
918
+ } catch (err) {
919
+ const msg = err instanceof Error ? err.message : String(err);
920
+ await safeSend(() =>
921
+ ctx.reply(`❌ Compact failed: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
922
+ );
923
+ }
924
+ }
925
+
926
+ // ---------------------------------------------------------------------------
927
+ // /oc_share — Share session and get URL
928
+ // ---------------------------------------------------------------------------
929
+
930
+ export async function ocShareCommand(ctx: Context): Promise<void> {
931
+ const chatId = ctx.chat?.id;
932
+ if (!chatId) return;
933
+
934
+ const sessionId = getActiveSessionId(chatId);
935
+ if (!sessionId) {
936
+ await safeSend(() => ctx.reply("No active session."));
937
+ return;
938
+ }
939
+
940
+ try {
941
+ const { data } = await getClient().session.share({
942
+ path: { id: sessionId },
943
+ });
944
+
945
+ const url = data?.share?.url;
946
+ if (url) {
947
+ await safeSend(() =>
948
+ ctx.reply(`🔗 Session shared:\n${url}`),
949
+ );
950
+ } else {
951
+ await safeSend(() => ctx.reply("✅ Session shared (no URL returned)."));
952
+ }
953
+ } catch (err) {
954
+ const msg = err instanceof Error ? err.message : String(err);
955
+ await safeSend(() =>
956
+ ctx.reply(`❌ Share failed: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
957
+ );
958
+ }
959
+ }
960
+
961
+ // ---------------------------------------------------------------------------
962
+ // /commands — List all available OpenCode commands
963
+ // ---------------------------------------------------------------------------
964
+
965
+ export async function commandsCommand(ctx: Context): Promise<void> {
966
+ try {
967
+ const { data: commands } = await getClient().command.list();
968
+
969
+ if (!commands || commands.length === 0) {
970
+ await safeSend(() => ctx.reply("No OpenCode commands available."));
971
+ return;
972
+ }
973
+
974
+ const lines = commands.map((cmd) => {
975
+ const desc = cmd.description ? ` — ${escapeHtml(cmd.description)}` : "";
976
+ const source = cmd.source ? ` <i>[${escapeHtml(cmd.source)}]</i>` : "";
977
+ return `• <code>/oc_${escapeHtml(cmd.name)}</code>${desc}${source}`;
978
+ });
979
+
980
+ await safeSend(() =>
981
+ ctx.reply(`<b>OpenCode Commands:</b>\n\n${lines.join("\n")}`, { parse_mode: "HTML" }),
982
+ );
983
+ } catch (err) {
984
+ const msg = err instanceof Error ? err.message : String(err);
985
+ await safeSend(() =>
986
+ ctx.reply(`❌ Failed to list commands: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
987
+ );
988
+ }
989
+ }
990
+
991
+ // ---------------------------------------------------------------------------
992
+ // Generic /oc_* handler — dispatch to session.command()
993
+ // ---------------------------------------------------------------------------
994
+
995
+ export async function ocGenericCommand(commandName: string, ctx: Context): Promise<void> {
996
+ const chatId = ctx.chat?.id;
997
+ if (!chatId) return;
998
+
999
+ const sessionId = getActiveSessionId(chatId);
1000
+ if (!sessionId) {
1001
+ await safeSend(() => ctx.reply("No active session."));
1002
+ return;
1003
+ }
1004
+
1005
+ const args = typeof ctx.match === "string" ? ctx.match.trim() : "";
1006
+
1007
+ try {
1008
+ await getClient().session.command({
1009
+ path: { id: sessionId },
1010
+ body: { command: commandName, arguments: args || "" },
1011
+ });
1012
+ await safeSend(() =>
1013
+ ctx.reply(`✅ Command <code>/${escapeHtml(commandName)}</code> sent.`, { parse_mode: "HTML" }),
1014
+ );
1015
+ } catch (err) {
1016
+ const msg = err instanceof Error ? err.message : String(err);
1017
+ await safeSend(() =>
1018
+ ctx.reply(`❌ Command failed: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
1019
+ );
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Discover OpenCode commands and return them for bot menu registration.
1025
+ */
1026
+ export async function discoverCommands(): Promise<OpenCodeCommand[]> {
1027
+ try {
1028
+ const { data } = await getClient().command.list();
1029
+ return data ?? [];
1030
+ } catch {
1031
+ return [];
1032
+ }
1033
+ }
@@ -3,6 +3,7 @@ import { getActiveSessionId, attachSession } from "../state/mode.js";
3
3
  import { getChatState } from "../state/store.js";
4
4
  import { safeSend } from "../utils/safeSend.js";
5
5
  import { escapeHtml } from "../utils/format.js";
6
+ import { executeShell } from "./commands.js";
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Client interface — only the methods used in this file
@@ -26,6 +27,10 @@ interface OpenCodeClient {
26
27
  };
27
28
  }): Promise<{ data: { info: unknown; parts: unknown[] } }>;
28
29
  };
30
+ postSessionIdPermissionsPermissionId(params: {
31
+ path: { id: string; permissionID: string };
32
+ body: { response: "once" | "always" | "reject" };
33
+ }): Promise<unknown>;
29
34
  }
30
35
 
31
36
  let _client: OpenCodeClient | null = null;
@@ -63,6 +68,85 @@ async function tryAutoAttach(chatId: number): Promise<string | null> {
63
68
  }
64
69
  }
65
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // Permission text-reply helper
73
+ // ---------------------------------------------------------------------------
74
+
75
+ type PermissionReply = "once" | "always" | "reject";
76
+
77
+ function parsePermissionReply(text: string): PermissionReply | null {
78
+ const lower = text.trim().toLowerCase();
79
+ if (lower === "yes" || lower === "y" || lower === "approve") return "once";
80
+ if (lower === "always" || lower === "yes always") return "always";
81
+ if (lower === "no" || lower === "n" || lower === "deny" || lower === "reject") return "reject";
82
+ return null;
83
+ }
84
+
85
+ const REPLY_LABELS: Record<PermissionReply, string> = {
86
+ once: "✅ Approved",
87
+ always: "✅ Always Allowed",
88
+ reject: "❌ Denied",
89
+ };
90
+
91
+ /**
92
+ * Try to resolve a text-based permission reply.
93
+ * Returns true if the message was handled as a permission reply.
94
+ */
95
+ async function tryPermissionReply(ctx: Context, chatId: number, text: string): Promise<boolean> {
96
+ const reply = parsePermissionReply(text);
97
+ if (!reply) return false;
98
+
99
+ const state = getChatState(chatId);
100
+ if (state.pendingPermissions.size === 0) return false;
101
+
102
+ // If replying to a specific permission message, match by telegramMessageId
103
+ const replyToId = ctx.message?.reply_to_message?.message_id;
104
+ let targetPerm: { permissionId: string; sessionId: string } | null = null;
105
+
106
+ if (replyToId) {
107
+ for (const perm of state.pendingPermissions.values()) {
108
+ if (perm.telegramMessageId === replyToId) {
109
+ targetPerm = { permissionId: perm.permissionId, sessionId: perm.sessionId };
110
+ break;
111
+ }
112
+ }
113
+ }
114
+
115
+ // Fallback: apply to most recent pending permission
116
+ if (!targetPerm) {
117
+ let latest: { permissionId: string; sessionId: string; timestamp: number } | null = null;
118
+ for (const perm of state.pendingPermissions.values()) {
119
+ if (!latest || perm.timestamp > latest.timestamp) {
120
+ latest = { permissionId: perm.permissionId, sessionId: perm.sessionId, timestamp: perm.timestamp };
121
+ }
122
+ }
123
+ if (latest) {
124
+ targetPerm = { permissionId: latest.permissionId, sessionId: latest.sessionId };
125
+ }
126
+ }
127
+
128
+ if (!targetPerm) return false;
129
+
130
+ try {
131
+ await getClient().postSessionIdPermissionsPermissionId({
132
+ path: { id: targetPerm.sessionId, permissionID: targetPerm.permissionId },
133
+ body: { response: reply },
134
+ });
135
+
136
+ state.pendingPermissions.delete(targetPerm.permissionId);
137
+ await safeSend(() =>
138
+ ctx.reply(REPLY_LABELS[reply], { parse_mode: "HTML" }),
139
+ );
140
+ } catch (err) {
141
+ const msg = err instanceof Error ? err.message : String(err);
142
+ await safeSend(() =>
143
+ ctx.reply(`❌ Permission reply failed: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
144
+ );
145
+ }
146
+
147
+ return true;
148
+ }
149
+
66
150
  // ---------------------------------------------------------------------------
67
151
  // Handler
68
152
  // ---------------------------------------------------------------------------
@@ -71,11 +155,10 @@ async function tryAutoAttach(chatId: number): Promise<string | null> {
71
155
  * Handles every plain-text message sent to the bot.
72
156
  *
73
157
  * Flow:
74
- * 1. Resolve (or auto-attach to) an active session.
75
- * 2. Fire the prompt against the OpenCode SDK — without awaiting the full
76
- * response, because the streaming reply arrives through event hooks
77
- * (message.updated) and is handled by a separate event listener.
78
- * 3. Propagate any errors back to the user.
158
+ * 1. Check for !<cmd> shell prefix.
159
+ * 2. Check for permission text replies (YES/NO/ALWAYS).
160
+ * 3. Resolve (or auto-attach to) an active session.
161
+ * 4. Fire the prompt against the OpenCode SDK.
79
162
  */
80
163
  export async function handleTextMessage(ctx: Context): Promise<void> {
81
164
  const chatId = ctx.chat?.id;
@@ -89,7 +172,24 @@ export async function handleTextMessage(ctx: Context): Promise<void> {
89
172
  if (text.startsWith("/")) return;
90
173
 
91
174
  // ------------------------------------------------------------------
92
- // 1. Resolve active session
175
+ // 1. Shell prefix: !<command>
176
+ // ------------------------------------------------------------------
177
+ if (text.startsWith("!")) {
178
+ const command = text.slice(1).trim();
179
+ if (command) {
180
+ await executeShell(ctx, chatId, command);
181
+ return;
182
+ }
183
+ }
184
+
185
+ // ------------------------------------------------------------------
186
+ // 2. Text-based permission replies (YES/NO/ALWAYS)
187
+ // ------------------------------------------------------------------
188
+ const handled = await tryPermissionReply(ctx, chatId, text);
189
+ if (handled) return;
190
+
191
+ // ------------------------------------------------------------------
192
+ // 3. Resolve active session
93
193
  // ------------------------------------------------------------------
94
194
  let sessionId = getActiveSessionId(chatId);
95
195
 
@@ -111,7 +211,7 @@ export async function handleTextMessage(ctx: Context): Promise<void> {
111
211
  }
112
212
 
113
213
  // ------------------------------------------------------------------
114
- // 2. Show typing indicator (best-effort)
214
+ // 4. Show typing indicator (best-effort)
115
215
  // ------------------------------------------------------------------
116
216
  try {
117
217
  await ctx.api.sendChatAction(chatId, "typing");
@@ -120,7 +220,7 @@ export async function handleTextMessage(ctx: Context): Promise<void> {
120
220
  }
121
221
 
122
222
  // ------------------------------------------------------------------
123
- // 3. Fire the prompt — response streams via event hooks, not here
223
+ // 5. Fire the prompt — response streams via event hooks, not here
124
224
  // ------------------------------------------------------------------
125
225
  const capturedSessionId = sessionId; // capture before any async gap
126
226
 
@@ -41,6 +41,10 @@ export function handlePermissionAsked(
41
41
  permissionId,
42
42
  sessionId: sessionID,
43
43
  });
44
+ const alwaysKey = registerCallback("perm_always", {
45
+ permissionId,
46
+ sessionId: sessionID,
47
+ });
44
48
  const denyKey = registerCallback("perm_deny", {
45
49
  permissionId,
46
50
  sessionId: sessionID,
@@ -48,12 +52,14 @@ export function handlePermissionAsked(
48
52
 
49
53
  const keyboard = new InlineKeyboard()
50
54
  .text("✅ Approve", approveKey)
55
+ .text("✅ Always", alwaysKey)
51
56
  .text("❌ Deny", denyKey);
52
57
 
53
58
  const messageText =
54
59
  `🔐 <b>Permission requested</b>\n\n` +
55
60
  `<b>Tool:</b> <code>${escapeHtml(tool)}</code>\n` +
56
- escapeHtml(description);
61
+ escapeHtml(description) +
62
+ `\n\n<i>Reply YES, ALWAYS, or NO</i>`;
57
63
 
58
64
  void (async () => {
59
65
  const result = await safeSend(() =>
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
2
 
3
- import { createBot, injectClient } from "./bot.js";
3
+ import { createBot, injectClient, registerBotMenu } from "./bot.js";
4
4
  import { initMapping } from "./state/mapping.js";
5
5
  import {
6
6
  resolveConfig,
@@ -269,6 +269,9 @@ export const TelegramPlugin: Plugin = async (ctx) => {
269
269
  message: "Telegram bot started (token from " + config.tokenSource + ").",
270
270
  },
271
271
  });
272
+
273
+ // Register bot commands in Telegram's menu (non-blocking)
274
+ void registerBotMenu(bot).catch(() => undefined);
272
275
  },
273
276
  allowed_updates: [
274
277
  "message",