@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 +30 -11
- package/package.json +1 -1
- package/src/bot.ts +88 -0
- package/src/handlers/callbacks.ts +37 -14
- package/src/handlers/commands.ts +471 -0
- package/src/handlers/messages.ts +108 -8
- package/src/hooks/permission.ts +7 -1
- package/src/index.ts +4 -1
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
|
|
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
|
-
| `/
|
|
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
|
-
|
|
|
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` |
|
|
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
|
|
157
|
-
| `/
|
|
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
|
-
|
|
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
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
|
-
|
|
13
|
-
path: { id: string;
|
|
14
|
-
body:
|
|
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().
|
|
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
|
|
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().
|
|
136
|
-
path: { id: sessionId, permissionId },
|
|
137
|
-
body: {
|
|
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
|
package/src/handlers/commands.ts
CHANGED
|
@@ -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 <cmd> — 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 & 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
|
+
}
|
package/src/handlers/messages.ts
CHANGED
|
@@ -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.
|
|
75
|
-
* 2.
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
package/src/hooks/permission.ts
CHANGED
|
@@ -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",
|