@tormentalabs/opencode-telegram-plugin 0.2.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.
@@ -0,0 +1,126 @@
1
+ import type { Api, RawApi } from "grammy";
2
+ import { getAllChatIds, getChatState, cleanupChatStream } from "../state/store.js";
3
+ import { getActiveSessionId } from "../state/mode.js";
4
+ import { escapeHtml } from "../utils/format.js";
5
+ import { safeSend } from "../utils/safeSend.js";
6
+ import { cleanupStream, gracefulFinalizeStream } from "./message.js";
7
+
8
+ export interface HookContext {
9
+ api: Api<RawApi>;
10
+ editIntervalMs: number;
11
+ }
12
+
13
+ /** Returns all chat IDs whose active session matches `sessionID` (any mode). */
14
+ function matchingChatIds(sessionID: string): number[] {
15
+ return getAllChatIds().filter(
16
+ (chatId) => getActiveSessionId(chatId) === sessionID,
17
+ );
18
+ }
19
+
20
+ /** Returns only "attached"-mode chat IDs whose active session matches `sessionID`. */
21
+ function attachedChatIds(sessionID: string): number[] {
22
+ return getAllChatIds().filter((chatId) => {
23
+ const cs = getChatState(chatId);
24
+ return cs.mode === "attached" && getActiveSessionId(chatId) === sessionID;
25
+ });
26
+ }
27
+
28
+ /** Gracefully finalize stream, clean up store, then send idle message. */
29
+ async function finalizeAndCleanup(chatId: number, api: Api<RawApi>): Promise<void> {
30
+ await gracefulFinalizeStream(chatId);
31
+ cleanupChatStream(chatId);
32
+ await safeSend(() =>
33
+ api.sendMessage(chatId, "💤 <i>Session idle</i>", {
34
+ parse_mode: "HTML",
35
+ }),
36
+ );
37
+ }
38
+
39
+ export function handleSessionCreated(
40
+ event: {
41
+ type: "session.created";
42
+ properties: { sessionID: string; title?: string };
43
+ },
44
+ ctx: HookContext,
45
+ ): void {
46
+ const { sessionID, title } = event.properties;
47
+ const { api } = ctx;
48
+
49
+ const displayName = title ? escapeHtml(title) : escapeHtml(sessionID);
50
+
51
+ for (const chatId of attachedChatIds(sessionID)) {
52
+ void safeSend(() =>
53
+ api.sendMessage(
54
+ chatId,
55
+ `🚀 <b>Session started</b>\n<code>${displayName}</code>`,
56
+ { parse_mode: "HTML" },
57
+ ),
58
+ );
59
+ }
60
+ }
61
+
62
+ export function handleSessionIdle(
63
+ event: {
64
+ type: "session.idle";
65
+ properties: { sessionID: string };
66
+ },
67
+ ctx: HookContext,
68
+ ): void {
69
+ const { sessionID } = event.properties;
70
+ const { api } = ctx;
71
+
72
+ for (const chatId of matchingChatIds(sessionID)) {
73
+ // Gracefully finalize any active stream (does final edit with latest text)
74
+ // then clean up. finalizeAndCleanup is async — fire-and-forget.
75
+ void finalizeAndCleanup(chatId, api);
76
+ }
77
+ }
78
+
79
+ export function handleSessionError(
80
+ event: {
81
+ type: "session.error";
82
+ properties: { sessionID: string; error: string };
83
+ },
84
+ ctx: HookContext,
85
+ ): void {
86
+ const { sessionID, error } = event.properties;
87
+ const { api } = ctx;
88
+
89
+ // Errors go to all matching chats regardless of mode
90
+ for (const chatId of matchingChatIds(sessionID)) {
91
+ // Gracefully finalize stream before reporting error
92
+ void (async () => {
93
+ await gracefulFinalizeStream(chatId);
94
+ cleanupChatStream(chatId);
95
+ await safeSend(() =>
96
+ api.sendMessage(
97
+ chatId,
98
+ `⚠️ <b>Error:</b> <code>${escapeHtml(error)}</code>`,
99
+ { parse_mode: "HTML" },
100
+ ),
101
+ );
102
+ })();
103
+ }
104
+ }
105
+
106
+ export function handleSessionStatus(
107
+ event: {
108
+ type: "session.status";
109
+ properties: { sessionID: string; status: string };
110
+ },
111
+ ctx: HookContext,
112
+ ): void {
113
+ const { sessionID, status } = event.properties;
114
+ const { api } = ctx;
115
+
116
+ // Skip empty, whitespace-only, or missing status strings to avoid noise
117
+ if (typeof status !== "string" || !status.trim()) return;
118
+
119
+ for (const chatId of attachedChatIds(sessionID)) {
120
+ void safeSend(() =>
121
+ api.sendMessage(chatId, `ℹ️ <i>${escapeHtml(status)}</i>`, {
122
+ parse_mode: "HTML",
123
+ }),
124
+ );
125
+ }
126
+ }
@@ -0,0 +1,99 @@
1
+ import type { Api, RawApi } from "grammy";
2
+ import { getAllChatIds, getChatState } from "../state/store.js";
3
+ import { getActiveSessionId } from "../state/mode.js";
4
+ import { escapeHtml } from "../utils/format.js";
5
+ import { safeSend } from "../utils/safeSend.js";
6
+
7
+ export interface HookContext {
8
+ api: Api<RawApi>;
9
+ editIntervalMs: number;
10
+ }
11
+
12
+ /**
13
+ * Tools that are long-running or high-impact enough to warrant a visible
14
+ * status message rather than a silent typing indicator.
15
+ */
16
+ const NOTABLE_TOOLS = new Set([
17
+ "bash",
18
+ "browser",
19
+ "computer",
20
+ "curl",
21
+ "docker",
22
+ "edit",
23
+ "execute",
24
+ "find",
25
+ "git",
26
+ "grep",
27
+ "node",
28
+ "npm",
29
+ "python",
30
+ "read_file",
31
+ "run",
32
+ "search",
33
+ "shell",
34
+ "web_search",
35
+ "write_file",
36
+ ]);
37
+
38
+ function isNotableTool(tool: string): boolean {
39
+ const key = tool.toLowerCase().replace(/[^a-z_]/g, "");
40
+ if (NOTABLE_TOOLS.has(key)) return true;
41
+ // Match common long-running prefixes
42
+ return (
43
+ key.startsWith("bash") ||
44
+ key.startsWith("browser") ||
45
+ key.startsWith("exec") ||
46
+ key.startsWith("run")
47
+ );
48
+ }
49
+
50
+ export function handleToolBefore(
51
+ event: {
52
+ type: "tool.execute.before";
53
+ properties: { sessionID: string; tool: string };
54
+ },
55
+ ctx: HookContext,
56
+ ): void {
57
+ const { sessionID, tool } = event.properties;
58
+ const { api } = ctx;
59
+
60
+ for (const chatId of getAllChatIds()) {
61
+ if (getActiveSessionId(chatId) !== sessionID) continue;
62
+
63
+ if (isNotableTool(tool)) {
64
+ // Send a brief visible status for high-impact tools
65
+ void safeSend(() =>
66
+ api.sendMessage(
67
+ chatId,
68
+ `🔧 Running <code>${escapeHtml(tool)}</code>…`,
69
+ { parse_mode: "HTML" },
70
+ ),
71
+ );
72
+ } else {
73
+ // For lightweight tools, just keep the typing indicator alive
74
+ void safeSend(() => api.sendChatAction(chatId, "typing"));
75
+ }
76
+ }
77
+ }
78
+
79
+ export function handleToolAfter(
80
+ event: {
81
+ type: "tool.execute.after";
82
+ properties: { sessionID: string; tool: string };
83
+ },
84
+ ctx: HookContext,
85
+ ): void {
86
+ const { sessionID } = event.properties;
87
+ const { api } = ctx;
88
+
89
+ for (const chatId of getAllChatIds()) {
90
+ if (getActiveSessionId(chatId) !== sessionID) continue;
91
+
92
+ const { stream } = getChatState(chatId);
93
+
94
+ // Keep the chat action alive if a stream response is still incoming
95
+ if (stream.state !== "IDLE" && stream.state !== "FINAL") {
96
+ void safeSend(() => api.sendChatAction(chatId, "typing"));
97
+ }
98
+ }
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,395 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+
3
+ import { createBot, injectClient } from "./bot.js";
4
+ import { initMapping } from "./state/mapping.js";
5
+ import {
6
+ resolveConfig,
7
+ readConfigFile,
8
+ writeConfigFile,
9
+ deleteConfigKey,
10
+ getConfigStatus,
11
+ getConfigPath,
12
+ } from "./config.js";
13
+
14
+ import {
15
+ handleMessageInfo,
16
+ handlePartUpdated,
17
+ handlePartDelta,
18
+ type HookContext,
19
+ type MessageUpdatedEvent,
20
+ type PartUpdatedEvent,
21
+ type PartDeltaEvent,
22
+ } from "./hooks/message.js";
23
+ import {
24
+ handleSessionCreated,
25
+ handleSessionIdle,
26
+ handleSessionError,
27
+ handleSessionStatus,
28
+ } from "./hooks/session.js";
29
+ import { handlePermissionAsked } from "./hooks/permission.js";
30
+ import { handleToolBefore, handleToolAfter } from "./hooks/tool.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // /telegram slash command handler
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function handleTelegramCommand(args: string | undefined): string {
37
+ const raw = (args ?? "").trim();
38
+ const parts = raw.split(/\s+/);
39
+ const cmd = (parts[0] || "help").toLowerCase();
40
+ const rest = parts.slice(1).join(" ").trim();
41
+
42
+ switch (cmd) {
43
+ case "set-token": {
44
+ if (!rest) {
45
+ return "**Usage**: `/telegram set-token <BOT_TOKEN>`\n\nGet a token from @BotFather on Telegram.";
46
+ }
47
+ // Basic validation: Telegram tokens are roughly <digits>:<alphanumeric+_->
48
+ if (!/^\d+:[A-Za-z0-9_-]+$/.test(rest)) {
49
+ return "**Invalid token format.** Expected format: `123456789:ABCdef-GHIjkl_MNOpqr`\n\nGet a token from @BotFather on Telegram.";
50
+ }
51
+ writeConfigFile({ botToken: rest });
52
+ const masked = rest.slice(0, 6) + "..." + rest.slice(-4);
53
+ return "**Bot token saved** to config file.\n\n- Token: `" + masked + "`\n- File: `" + getConfigPath() + "`\n\n**Restart OpenCode** for the change to take effect.";
54
+ }
55
+
56
+ case "remove-token": {
57
+ deleteConfigKey("botToken");
58
+ return "**Bot token removed** from config file.\n\n**Restart OpenCode** for the change to take effect.";
59
+ }
60
+
61
+ case "set-users": {
62
+ if (!rest) {
63
+ return "**Usage**: `/telegram set-users <user_id1,user_id2,...>`\n\nSet comma-separated Telegram user IDs that are allowed to use the bot.\nUse `/telegram remove-users` to allow all users.";
64
+ }
65
+ // Validate: all parts should be numeric
66
+ const ids = rest.split(",").map(s => s.trim()).filter(Boolean);
67
+ const invalid = ids.filter(id => !/^\d+$/.test(id));
68
+ if (invalid.length > 0) {
69
+ return "**Invalid user ID(s)**: " + invalid.map(id => "`" + id + "`").join(", ") + "\n\nUser IDs must be numeric. Message @jsondumpbot on Telegram to get your ID (look for the `from.id` field).";
70
+ }
71
+ writeConfigFile({ allowedUsers: ids.join(",") });
72
+ return "**Allowed users saved**: " + ids.map(id => "`" + id + "`").join(", ") + "\n\n**Restart OpenCode** for the change to take effect.";
73
+ }
74
+
75
+ case "remove-users": {
76
+ deleteConfigKey("allowedUsers");
77
+ return "**User restriction removed.** All users will be allowed.\n\n**Restart OpenCode** for the change to take effect.";
78
+ }
79
+
80
+ case "set-interval": {
81
+ const ms = Number(rest);
82
+ if (!rest || !Number.isFinite(ms) || ms <= 0) {
83
+ return "**Usage**: `/telegram set-interval <milliseconds>`\n\nSet the minimum interval between message edits during streaming.\nDefault: `2500` (2.5 seconds).";
84
+ }
85
+ writeConfigFile({ editIntervalMs: ms });
86
+ return "**Edit interval saved**: `" + ms + "ms`\n\n**Restart OpenCode** for the change to take effect.";
87
+ }
88
+
89
+ case "auto-attach": {
90
+ if (rest === "on" || rest === "true") {
91
+ writeConfigFile({ autoAttach: true });
92
+ return "**Auto-attach enabled.** Bot will auto-attach to the active session on `/start`.\n\n**Restart OpenCode** for the change to take effect.";
93
+ } else if (rest === "off" || rest === "false") {
94
+ writeConfigFile({ autoAttach: false });
95
+ return "**Auto-attach disabled.** Use `/attach` manually.\n\n**Restart OpenCode** for the change to take effect.";
96
+ }
97
+ return "**Usage**: `/telegram auto-attach <on|off>`";
98
+ }
99
+
100
+ case "status": {
101
+ return getConfigStatus();
102
+ }
103
+
104
+ case "path": {
105
+ return "**Config file path**: `" + getConfigPath() + "`";
106
+ }
107
+
108
+ case "show": {
109
+ const file = readConfigFile();
110
+ if (Object.keys(file).length === 0) {
111
+ return "**Config file is empty or doesn't exist.**\n\nPath: `" + getConfigPath() + "`\nUse `/telegram set-token <TOKEN>` to get started.";
112
+ }
113
+ // Mask the token for display
114
+ const display: Record<string, unknown> = { ...file };
115
+ if (typeof display.botToken === "string") {
116
+ const t = display.botToken as string;
117
+ display.botToken = t.slice(0, 6) + "..." + t.slice(-4);
118
+ }
119
+ return "**Config file contents** (`" + getConfigPath() + "`):\n\n```json\n" + JSON.stringify(display, null, 2) + "\n```";
120
+ }
121
+
122
+ case "help":
123
+ default: {
124
+ return [
125
+ "**Telegram Plugin Configuration**\n",
126
+ "**Commands**:",
127
+ "- `/telegram set-token <TOKEN>` — Save bot token (from @BotFather)",
128
+ "- `/telegram remove-token` — Remove saved bot token",
129
+ "- `/telegram set-users <id1,id2>` — Restrict bot to specific user IDs",
130
+ "- `/telegram remove-users` — Allow all users",
131
+ "- `/telegram set-interval <ms>` — Set edit throttle interval (default: 2500)",
132
+ "- `/telegram auto-attach <on|off>` — Toggle auto-attach on /start",
133
+ "- `/telegram status` — Show resolved config (file + env)",
134
+ "- `/telegram show` — Show raw config file contents",
135
+ "- `/telegram path` — Show config file location",
136
+ "- `/telegram help` — Show this help\n",
137
+ "**Config file**: `~/.config/opencode/telegram.json`",
138
+ "**Priority**: env vars override config file values.\n",
139
+ "**Quick start**:",
140
+ "1. `/telegram set-token 123456:ABC-DEF...`",
141
+ "2. `/telegram set-users <your_telegram_id>`",
142
+ "3. Restart OpenCode",
143
+ ].join("\n");
144
+ }
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Plugin entry point
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export const TelegramPlugin: Plugin = async (ctx) => {
153
+ const { client, directory } = ctx;
154
+
155
+ // ── Resolve configuration (config file + env vars) ─────────────────────
156
+ let config: ReturnType<typeof resolveConfig>;
157
+ try {
158
+ config = resolveConfig();
159
+ } catch (err) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ await client.app.log({
162
+ body: {
163
+ service: "telegram-plugin",
164
+ level: "error",
165
+ message: "Failed to resolve config: " + msg,
166
+ },
167
+ });
168
+ return {};
169
+ }
170
+
171
+ if (!config.botToken) {
172
+ await client.app.log({
173
+ body: {
174
+ service: "telegram-plugin",
175
+ level: "warn",
176
+ message:
177
+ "No bot token found (env or config file) — Telegram bot disabled. Use /telegram set-token to configure.",
178
+ },
179
+ });
180
+
181
+ // Even without a token, register the /telegram command so users can configure
182
+ return {
183
+ config: async (opencodeConfig: any) => {
184
+ opencodeConfig.command ??= {};
185
+ opencodeConfig.command["telegram"] = {
186
+ template: "$ARGUMENTS",
187
+ description: "Configure the Telegram plugin (set-token, set-users, status, help)",
188
+ };
189
+ },
190
+ "command.execute.before": async (input: any, output: any) => {
191
+ if (input.command === "telegram") {
192
+ output.parts.push({
193
+ type: "text" as const,
194
+ text: handleTelegramCommand(input.arguments),
195
+ });
196
+ }
197
+ },
198
+ };
199
+ }
200
+
201
+ // ── Persistent mapping store ──────────────────────────────────────────
202
+ const dataDir = directory + "/.opencode/telegram";
203
+ try {
204
+ initMapping(dataDir);
205
+ } catch (err) {
206
+ const msg = err instanceof Error ? err.message : String(err);
207
+ await client.app.log({
208
+ body: {
209
+ service: "telegram-plugin",
210
+ level: "error",
211
+ message: "Failed to init mapping store: " + msg,
212
+ },
213
+ });
214
+ }
215
+
216
+ // ── Create bot ────────────────────────────────────────────────────────
217
+ const maskedToken = config.botToken.slice(0, 6) + "..." + config.botToken.slice(-4);
218
+ await client.app.log({
219
+ body: {
220
+ service: "telegram-plugin",
221
+ level: "info",
222
+ message: "Initializing Telegram bot (token: " + maskedToken + ", source: " + config.tokenSource + ", allowed_users: " + (config.allowedUsers || "all") + ")",
223
+ },
224
+ });
225
+
226
+ let bot: ReturnType<typeof createBot>;
227
+ try {
228
+ bot = createBot({
229
+ token: config.botToken,
230
+ allowedUsers: config.allowedUsers,
231
+ onError: (message, error) => {
232
+ const detail = error instanceof Error ? error.message : String(error);
233
+ void client.app.log({
234
+ body: {
235
+ service: "telegram-plugin",
236
+ level: "error",
237
+ message: message + " " + detail,
238
+ },
239
+ });
240
+ },
241
+ });
242
+ injectClient(client);
243
+ } catch (err) {
244
+ const msg = err instanceof Error ? err.message : String(err);
245
+ await client.app.log({
246
+ body: {
247
+ service: "telegram-plugin",
248
+ level: "error",
249
+ message: "Failed to create bot: " + msg,
250
+ },
251
+ });
252
+ return {};
253
+ }
254
+
255
+ // ── Hook context (shared by all event-driven hooks) ───────────────────
256
+ const hookCtx: HookContext = {
257
+ api: bot.api,
258
+ editIntervalMs: config.editIntervalMs,
259
+ };
260
+
261
+ // ── Start polling ─────────────────────────────────────────────────────
262
+ void bot.start({
263
+ drop_pending_updates: true,
264
+ onStart: () => {
265
+ void client.app.log({
266
+ body: {
267
+ service: "telegram-plugin",
268
+ level: "info",
269
+ message: "Telegram bot started (token from " + config.tokenSource + ").",
270
+ },
271
+ });
272
+ },
273
+ allowed_updates: [
274
+ "message",
275
+ "callback_query",
276
+ ],
277
+ }).catch((err: unknown) => {
278
+ const msg = err instanceof Error ? err.message : String(err);
279
+ void client.app.log({
280
+ body: {
281
+ service: "telegram-plugin",
282
+ level: "error",
283
+ message: "Telegram bot failed to start: " + msg,
284
+ },
285
+ });
286
+ });
287
+
288
+ // ── Graceful shutdown ─────────────────────────────────────────────────
289
+ let stopping = false;
290
+ function shutdown(): void {
291
+ if (stopping) return;
292
+ stopping = true;
293
+ process.off("SIGINT", shutdown);
294
+ bot.stop().catch(() => undefined);
295
+ }
296
+
297
+ process.on("SIGINT", shutdown);
298
+
299
+ // ── Return hooks ──────────────────────────────────────────────────────
300
+ return {
301
+ // ── /telegram slash command ────────────────────────────────────────
302
+ config: async (opencodeConfig: any) => {
303
+ opencodeConfig.command ??= {};
304
+ opencodeConfig.command["telegram"] = {
305
+ template: "$ARGUMENTS",
306
+ description: "Configure the Telegram plugin (set-token, set-users, status, help)",
307
+ };
308
+ },
309
+
310
+ "command.execute.before": async (input: any, output: any) => {
311
+ if (input.command === "telegram") {
312
+ output.parts.push({
313
+ type: "text" as const,
314
+ text: handleTelegramCommand(input.arguments),
315
+ });
316
+ }
317
+ },
318
+
319
+ // ── Event dispatcher ──────────────────────────────────────────────
320
+ event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
321
+ if (!event.properties || typeof event.properties !== "object") return;
322
+
323
+ switch (event.type) {
324
+ // ── Streaming: delta events carry incremental text chunks ──────
325
+ case "message.part.delta":
326
+ handlePartDelta(event as PartDeltaEvent, hookCtx);
327
+ break;
328
+
329
+ // ── Part updated: full snapshot of a single part ──────────────
330
+ case "message.part.updated":
331
+ handlePartUpdated(event as PartUpdatedEvent, hookCtx);
332
+ break;
333
+
334
+ // ── message.updated: metadata — track assistant message IDs
335
+ case "message.updated":
336
+ handleMessageInfo(event as MessageUpdatedEvent);
337
+ break;
338
+
339
+ case "session.created":
340
+ handleSessionCreated(
341
+ event as Parameters<typeof handleSessionCreated>[0],
342
+ hookCtx,
343
+ );
344
+ break;
345
+
346
+ case "session.idle":
347
+ handleSessionIdle(
348
+ event as Parameters<typeof handleSessionIdle>[0],
349
+ hookCtx,
350
+ );
351
+ break;
352
+
353
+ case "session.error":
354
+ handleSessionError(
355
+ event as Parameters<typeof handleSessionError>[0],
356
+ hookCtx,
357
+ );
358
+ break;
359
+
360
+ case "session.status":
361
+ handleSessionStatus(
362
+ event as Parameters<typeof handleSessionStatus>[0],
363
+ hookCtx,
364
+ );
365
+ break;
366
+
367
+ case "permission.asked":
368
+ handlePermissionAsked(
369
+ event as Parameters<typeof handlePermissionAsked>[0],
370
+ hookCtx,
371
+ );
372
+ break;
373
+
374
+ case "tool.execute.before":
375
+ handleToolBefore(
376
+ event as Parameters<typeof handleToolBefore>[0],
377
+ hookCtx,
378
+ );
379
+ break;
380
+
381
+ case "tool.execute.after":
382
+ handleToolAfter(
383
+ event as Parameters<typeof handleToolAfter>[0],
384
+ hookCtx,
385
+ );
386
+ break;
387
+
388
+ default:
389
+ break;
390
+ }
391
+ },
392
+ };
393
+ };
394
+
395
+ export default TelegramPlugin;