@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,562 @@
1
+ import type { Context } from "grammy";
2
+ import { InlineKeyboard } from "grammy";
3
+ import { getChatState, cleanupChatStream, registerCallback, type SelectedModel, type EffortLevel } from "../state/store.js";
4
+ import {
5
+ getActiveSessionId,
6
+ getMode,
7
+ attachSession,
8
+ detachSession,
9
+ startIndependentSession,
10
+ } from "../state/mode.js";
11
+ import { setMapping } from "../state/mapping.js";
12
+ import { safeSend } from "../utils/safeSend.js";
13
+ import { escapeHtml } from "../utils/format.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Client interface — only the methods used in this file
17
+ // ---------------------------------------------------------------------------
18
+
19
+ interface SessionSummary {
20
+ id: string;
21
+ title: string;
22
+ createdAt: string;
23
+ }
24
+
25
+ interface OpenCodeClient {
26
+ session: {
27
+ list(): Promise<{ data: SessionSummary[] }>;
28
+ create(params: { body: { title: string } }): Promise<{ data: { id: string } }>;
29
+ abort(params: { path: { id: string } }): Promise<boolean>;
30
+ };
31
+ config: {
32
+ providers(): Promise<{
33
+ data: {
34
+ providers: Array<{
35
+ id: string;
36
+ name: string;
37
+ models: Record<string, { id: string; name: string }>;
38
+ }>;
39
+ };
40
+ }>;
41
+ };
42
+ }
43
+
44
+ let _client: OpenCodeClient | null = null;
45
+
46
+ export function setClient(client: unknown): void {
47
+ _client = client as OpenCodeClient;
48
+ }
49
+
50
+ function getClient(): OpenCodeClient {
51
+ if (!_client) throw new Error("OpenCode client not initialized");
52
+ return _client;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const HELP_TEXT = `
60
+ <b>OpenCode Telegram Bot</b> 🤖
61
+
62
+ <b>Session Management</b>
63
+ /attach [id] — Attach to an existing session (lists sessions if no ID given)
64
+ /detach — Detach from the current session
65
+ /new [title] — Create and attach to a new independent session
66
+ /switch [id] — Switch to a different session
67
+ /sessions — List all available sessions
68
+
69
+ <b>While in a Session</b>
70
+ Just send a message to prompt OpenCode
71
+ /abort — Abort the current running operation
72
+
73
+ <b>Model &amp; Config</b>
74
+ /model — List available models
75
+ /model provider/id — Set active model (e.g. <code>/model anthropic/claude-sonnet-4-20250514</code>)
76
+ /model reset — Reset to default model
77
+ /effort [low|medium|high] — Set reasoning effort (default: high)
78
+ /status — Show current bot status
79
+ /help — Show this help message
80
+ `.trim();
81
+
82
+ /**
83
+ * Returns sessions sorted newest-first, capped at `limit`.
84
+ */
85
+ function sortedSessions(sessions: SessionSummary[], limit = 10): SessionSummary[] {
86
+ return [...sessions]
87
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
88
+ .slice(0, limit);
89
+ }
90
+
91
+ /**
92
+ * Builds an InlineKeyboard where each button attaches to a session.
93
+ */
94
+ function buildSessionKeyboard(sessions: SessionSummary[]): InlineKeyboard {
95
+ const keyboard = new InlineKeyboard();
96
+ for (const session of sessions) {
97
+ const label = `${session.title || "Untitled"} (${session.id.slice(0, 8)}…)`;
98
+ const key = registerCallback("attach_session", { sessionId: session.id });
99
+ keyboard.text(label, key).row();
100
+ }
101
+ return keyboard;
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Command handlers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export async function startCommand(ctx: Context): Promise<void> {
109
+ const chatId = ctx.chat?.id;
110
+ if (!chatId) return;
111
+
112
+ // Register the chat mapping (username may be undefined for users with no username)
113
+ setMapping(chatId, { username: ctx.from?.username });
114
+
115
+ await safeSend(() =>
116
+ ctx.reply(
117
+ `Welcome to <b>OpenCode Bot</b>! 🤖\n\n` +
118
+ `I give you Telegram access to OpenCode — an AI coding assistant.\n\n` +
119
+ `Send me any message to prompt OpenCode, or use /help to see all commands.`,
120
+ { parse_mode: "HTML" },
121
+ ),
122
+ );
123
+
124
+ // Auto-attach to the most recent session when enabled (default: on)
125
+ const autoAttach = process.env["TELEGRAM_AUTO_ATTACH"] !== "false";
126
+ if (!autoAttach) return;
127
+
128
+ try {
129
+ const { data: sessions } = await getClient().session.list();
130
+ if (sessions.length === 0) return;
131
+
132
+ const latest = sortedSessions(sessions, 1)[0]!;
133
+ attachSession(chatId, latest.id);
134
+
135
+ await safeSend(() =>
136
+ ctx.reply(
137
+ `✅ Auto-attached to: <b>${escapeHtml(latest.title || "Untitled")}</b>\n` +
138
+ `<code>${escapeHtml(latest.id)}</code>`,
139
+ { parse_mode: "HTML" },
140
+ ),
141
+ );
142
+ } catch {
143
+ // Auto-attach failure is non-fatal — the user can attach manually
144
+ }
145
+ }
146
+
147
+ export async function helpCommand(ctx: Context): Promise<void> {
148
+ await safeSend(() => ctx.reply(HELP_TEXT, { parse_mode: "HTML" }));
149
+ }
150
+
151
+ export async function attachCommand(ctx: Context): Promise<void> {
152
+ const chatId = ctx.chat?.id;
153
+ if (!chatId) return;
154
+
155
+ const sessionId = typeof ctx.match === "string" ? ctx.match.trim() : undefined;
156
+
157
+ if (sessionId) {
158
+ attachSession(chatId, sessionId);
159
+ await safeSend(() =>
160
+ ctx.reply(
161
+ `✅ Attached to session:\n<code>${escapeHtml(sessionId)}</code>`,
162
+ { parse_mode: "HTML" },
163
+ ),
164
+ );
165
+ return;
166
+ }
167
+
168
+ // No session ID provided — show a picker
169
+ try {
170
+ const { data: sessions } = await getClient().session.list();
171
+
172
+ if (sessions.length === 0) {
173
+ await safeSend(() =>
174
+ ctx.reply("No sessions found. Use /new to create one."),
175
+ );
176
+ return;
177
+ }
178
+
179
+ const recent = sortedSessions(sessions);
180
+ const keyboard = buildSessionKeyboard(recent);
181
+ const note =
182
+ sessions.length > 10
183
+ ? ` (showing 10 of ${sessions.length} most recent)`
184
+ : "";
185
+
186
+ await safeSend(() =>
187
+ ctx.reply(`Select a session to attach to${note}:`, {
188
+ reply_markup: keyboard,
189
+ }),
190
+ );
191
+ } catch (err) {
192
+ const msg = err instanceof Error ? err.message : String(err);
193
+ await safeSend(() =>
194
+ ctx.reply(`❌ Failed to list sessions: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
195
+ );
196
+ }
197
+ }
198
+
199
+ export async function detachCommand(ctx: Context): Promise<void> {
200
+ const chatId = ctx.chat?.id;
201
+ if (!chatId) return;
202
+
203
+ detachSession(chatId);
204
+ await safeSend(() =>
205
+ ctx.reply("🔌 Detached. Use /attach or /new to start a session."),
206
+ );
207
+ }
208
+
209
+ export async function newCommand(ctx: Context): Promise<void> {
210
+ const chatId = ctx.chat?.id;
211
+ if (!chatId) return;
212
+
213
+ const title =
214
+ (typeof ctx.match === "string" ? ctx.match.trim() : "") || "Telegram Session";
215
+
216
+ try {
217
+ const { data } = await getClient().session.create({ body: { title } });
218
+ startIndependentSession(chatId, data.id);
219
+
220
+ await safeSend(() =>
221
+ ctx.reply(
222
+ `✅ Created new session:\n` +
223
+ `<b>${escapeHtml(title)}</b>\n` +
224
+ `<code>${escapeHtml(data.id)}</code>`,
225
+ { parse_mode: "HTML" },
226
+ ),
227
+ );
228
+ } catch (err) {
229
+ const msg = err instanceof Error ? err.message : String(err);
230
+ await safeSend(() =>
231
+ ctx.reply(`❌ Failed to create session: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
232
+ );
233
+ }
234
+ }
235
+
236
+ export async function sessionsCommand(ctx: Context): Promise<void> {
237
+ const chatId = ctx.chat?.id;
238
+ if (!chatId) return;
239
+
240
+ try {
241
+ const { data: sessions } = await getClient().session.list();
242
+
243
+ if (sessions.length === 0) {
244
+ await safeSend(() =>
245
+ ctx.reply("No sessions found. Use /new to create one."),
246
+ );
247
+ return;
248
+ }
249
+
250
+ const activeId = getActiveSessionId(chatId);
251
+ const recent = sortedSessions(sessions);
252
+
253
+ const lines = recent.map((s, i) => {
254
+ const active = s.id === activeId ? " ✅" : "";
255
+ const title = escapeHtml(s.title || "Untitled");
256
+ const shortId = escapeHtml(s.id.slice(0, 12));
257
+ return `${i + 1}. <b>${title}</b>${active}\n <code>${shortId}…</code>`;
258
+ });
259
+
260
+ const header =
261
+ sessions.length > 10
262
+ ? `Showing 10 of ${sessions.length} sessions (most recent):\n\n`
263
+ : `<b>${sessions.length} session${sessions.length === 1 ? "" : "s"}:</b>\n\n`;
264
+
265
+ await safeSend(() =>
266
+ ctx.reply(header + lines.join("\n\n"), { parse_mode: "HTML" }),
267
+ );
268
+ } catch (err) {
269
+ const msg = err instanceof Error ? err.message : String(err);
270
+ await safeSend(() =>
271
+ ctx.reply(`❌ Failed to list sessions: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
272
+ );
273
+ }
274
+ }
275
+
276
+ export async function switchCommand(ctx: Context): Promise<void> {
277
+ const chatId = ctx.chat?.id;
278
+ if (!chatId) return;
279
+
280
+ const sessionId = typeof ctx.match === "string" ? ctx.match.trim() : undefined;
281
+
282
+ if (sessionId) {
283
+ const mode = getMode(chatId);
284
+ // Preserve mode semantics: re-attach if attached, switch independent otherwise
285
+ if (mode === "attached") {
286
+ attachSession(chatId, sessionId);
287
+ } else {
288
+ startIndependentSession(chatId, sessionId);
289
+ }
290
+
291
+ await safeSend(() =>
292
+ ctx.reply(
293
+ `✅ Switched to session:\n<code>${escapeHtml(sessionId)}</code>`,
294
+ { parse_mode: "HTML" },
295
+ ),
296
+ );
297
+ return;
298
+ }
299
+
300
+ // No ID provided — show a picker (same UX as /attach)
301
+ try {
302
+ const { data: sessions } = await getClient().session.list();
303
+
304
+ if (sessions.length === 0) {
305
+ await safeSend(() =>
306
+ ctx.reply("No sessions found. Use /new to create one."),
307
+ );
308
+ return;
309
+ }
310
+
311
+ const recent = sortedSessions(sessions);
312
+ const keyboard = buildSessionKeyboard(recent);
313
+ const note =
314
+ sessions.length > 10
315
+ ? ` (showing 10 of ${sessions.length} most recent)`
316
+ : "";
317
+
318
+ await safeSend(() =>
319
+ ctx.reply(`Select a session to switch to${note}:`, {
320
+ reply_markup: keyboard,
321
+ }),
322
+ );
323
+ } catch (err) {
324
+ const msg = err instanceof Error ? err.message : String(err);
325
+ await safeSend(() =>
326
+ ctx.reply(`❌ Failed to list sessions: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
327
+ );
328
+ }
329
+ }
330
+
331
+ export async function modelCommand(ctx: Context): Promise<void> {
332
+ const chatId = ctx.chat?.id;
333
+ if (!chatId) return;
334
+
335
+ const arg = typeof ctx.match === "string" ? ctx.match.trim() : "";
336
+
337
+ // /model reset — clear override
338
+ if (arg.toLowerCase() === "reset") {
339
+ getChatState(chatId).selectedModel = null;
340
+ await safeSend(() =>
341
+ ctx.reply("✅ Model reset to default.", { parse_mode: "HTML" }),
342
+ );
343
+ return;
344
+ }
345
+
346
+ // /model provider/model-id — set model
347
+ if (arg && arg.includes("/")) {
348
+ const slashIdx = arg.indexOf("/");
349
+ const providerID = arg.substring(0, slashIdx);
350
+ const modelID = arg.substring(slashIdx + 1);
351
+
352
+ if (!providerID || !modelID) {
353
+ await safeSend(() =>
354
+ ctx.reply("Usage: <code>/model provider/model-id</code>\nExample: <code>/model anthropic/claude-sonnet-4-20250514</code>", { parse_mode: "HTML" }),
355
+ );
356
+ return;
357
+ }
358
+
359
+ // Validate provider exists, but allow any model ID (favorites may not be in models list)
360
+ try {
361
+ const { data } = await getClient().config.providers();
362
+ const providers = data?.providers ?? [];
363
+ const provider = providers.find((p) => p.id === providerID);
364
+
365
+ if (!provider) {
366
+ const available = providers.map((p) => p.id).join(", ");
367
+ await safeSend(() =>
368
+ ctx.reply(`❌ Unknown provider: <code>${escapeHtml(providerID)}</code>\nAvailable: ${available}`, { parse_mode: "HTML" }),
369
+ );
370
+ return;
371
+ }
372
+
373
+ // Try to resolve display name from models list, fall back to raw ID
374
+ const model = (provider.models ?? {})[modelID];
375
+ const displayName = model?.name ?? modelID;
376
+
377
+ const selected: SelectedModel = {
378
+ providerID,
379
+ modelID,
380
+ displayName,
381
+ };
382
+ getChatState(chatId).selectedModel = selected;
383
+
384
+ await safeSend(() =>
385
+ ctx.reply(
386
+ `✅ Model set to:\n<b>${escapeHtml(selected.displayName)}</b>\n<code>${escapeHtml(providerID)}/${escapeHtml(modelID)}</code>`,
387
+ { parse_mode: "HTML" },
388
+ ),
389
+ );
390
+ } catch (err) {
391
+ const msg = err instanceof Error ? err.message : String(err);
392
+ await safeSend(() =>
393
+ ctx.reply(`❌ Failed to validate model: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
394
+ );
395
+ }
396
+ return;
397
+ }
398
+
399
+ // /model (no args) — list available models
400
+ try {
401
+ const { data } = await getClient().config.providers();
402
+ const providers = data?.providers ?? [];
403
+
404
+ if (providers.length === 0) {
405
+ await safeSend(() => ctx.reply("No models configured."));
406
+ return;
407
+ }
408
+
409
+ // Show current selection
410
+ const state = getChatState(chatId);
411
+ const currentLine = state.selectedModel
412
+ ? `Current: <b>${escapeHtml(state.selectedModel.displayName)}</b> (<code>${escapeHtml(state.selectedModel.providerID)}/${escapeHtml(state.selectedModel.modelID)}</code>)`
413
+ : "Current: <i>default</i>";
414
+
415
+ // Sort providers alphabetically by display name
416
+ const sorted = [...providers].sort((a, b) =>
417
+ (a.name || a.id).localeCompare(b.name || b.id),
418
+ );
419
+
420
+ // Build per-provider blocks with models sorted alphabetically by name
421
+ const blocks: string[] = [];
422
+ for (const provider of sorted) {
423
+ const modelEntries = Object.entries(provider.models ?? {});
424
+ if (modelEntries.length === 0) continue;
425
+
426
+ const modelLines = modelEntries
427
+ .sort(([, a], [, b]) => (a.name ?? "").localeCompare(b.name ?? ""))
428
+ .map(
429
+ ([id, model]) =>
430
+ ` • <code>${escapeHtml(id)}</code> — ${escapeHtml(model.name ?? id)}`,
431
+ );
432
+
433
+ blocks.push(
434
+ `<b>${escapeHtml(provider.name || provider.id)}</b>\n${modelLines.join("\n")}`,
435
+ );
436
+ }
437
+
438
+ if (blocks.length === 0) {
439
+ await safeSend(() => ctx.reply("No models available."));
440
+ return;
441
+ }
442
+
443
+ // Assemble output
444
+ const MAX_LEN = 4000;
445
+ let header = `<b>Available Models:</b>\n${currentLine}\n`;
446
+ header += `\nUse <code>/model provider/model-id</code> to set.\n`;
447
+
448
+ let current = header;
449
+ for (const block of blocks) {
450
+ if (current.length + block.length + 2 > MAX_LEN) {
451
+ await safeSend(() =>
452
+ ctx.reply(current, { parse_mode: "HTML" }),
453
+ );
454
+ current = "";
455
+ }
456
+ current += "\n" + block + "\n";
457
+ }
458
+ if (current.trim()) {
459
+ await safeSend(() =>
460
+ ctx.reply(current, { parse_mode: "HTML" }),
461
+ );
462
+ }
463
+ } catch (err) {
464
+ const msg = err instanceof Error ? err.message : String(err);
465
+ await safeSend(() =>
466
+ ctx.reply(`❌ Failed to fetch models: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
467
+ );
468
+ }
469
+ }
470
+
471
+ export async function effortCommand(ctx: Context): Promise<void> {
472
+ const chatId = ctx.chat?.id;
473
+ if (!chatId) return;
474
+
475
+ const arg = typeof ctx.match === "string" ? ctx.match.trim().toLowerCase() : "";
476
+ const state = getChatState(chatId);
477
+
478
+ if (!arg) {
479
+ await safeSend(() =>
480
+ ctx.reply(
481
+ `Current effort: <b>${escapeHtml(state.effort)}</b>\n\nUsage: <code>/effort low|medium|high</code>`,
482
+ { parse_mode: "HTML" },
483
+ ),
484
+ );
485
+ return;
486
+ }
487
+
488
+ const validEfforts: EffortLevel[] = ["low", "medium", "high"];
489
+ if (!validEfforts.includes(arg as EffortLevel)) {
490
+ await safeSend(() =>
491
+ ctx.reply(
492
+ `❌ Invalid effort level: <code>${escapeHtml(arg)}</code>\nValid: <code>low</code>, <code>medium</code>, <code>high</code>`,
493
+ { parse_mode: "HTML" },
494
+ ),
495
+ );
496
+ return;
497
+ }
498
+
499
+ state.effort = arg as EffortLevel;
500
+ const emoji = { low: "🔋", medium: "⚡", high: "🔥" }[state.effort];
501
+ await safeSend(() =>
502
+ ctx.reply(`${emoji} Effort set to: <b>${escapeHtml(state.effort)}</b>`, { parse_mode: "HTML" }),
503
+ );
504
+ }
505
+
506
+ export async function statusCommand(ctx: Context): Promise<void> {
507
+ const chatId = ctx.chat?.id;
508
+ if (!chatId) return;
509
+
510
+ const mode = getMode(chatId);
511
+ const activeId = getActiveSessionId(chatId);
512
+ const state = getChatState(chatId);
513
+
514
+ const modeEmoji: Record<string, string> = {
515
+ attached: "🔗",
516
+ independent: "🆓",
517
+ detached: "🔌",
518
+ };
519
+
520
+ const effortEmoji = { low: "🔋", medium: "⚡", high: "🔥" }[state.effort] ?? "❓";
521
+ const modelLine = state.selectedModel
522
+ ? `<code>${escapeHtml(state.selectedModel.providerID)}/${escapeHtml(state.selectedModel.modelID)}</code>`
523
+ : "<i>default</i>";
524
+
525
+ const lines = [
526
+ `<b>Bot Status</b>`,
527
+ ``,
528
+ `Mode: ${modeEmoji[mode] ?? "❓"} <b>${escapeHtml(mode)}</b>`,
529
+ activeId
530
+ ? `Session: <code>${escapeHtml(activeId)}</code>`
531
+ : `Session: <i>none</i>`,
532
+ `Model: ${modelLine}`,
533
+ `Effort: ${effortEmoji} <b>${escapeHtml(state.effort)}</b>`,
534
+ `Stream: ${state.stream.state !== "IDLE" && state.stream.state !== "FINAL" ? "⏳ active" : "⬜ idle"}`,
535
+ ];
536
+
537
+ await safeSend(() =>
538
+ ctx.reply(lines.join("\n"), { parse_mode: "HTML" }),
539
+ );
540
+ }
541
+
542
+ export async function abortCommand(ctx: Context): Promise<void> {
543
+ const chatId = ctx.chat?.id;
544
+ if (!chatId) return;
545
+
546
+ const activeId = getActiveSessionId(chatId);
547
+ if (!activeId) {
548
+ await safeSend(() => ctx.reply("No active session to abort."));
549
+ return;
550
+ }
551
+
552
+ try {
553
+ await getClient().session.abort({ path: { id: activeId } });
554
+ cleanupChatStream(chatId);
555
+ await safeSend(() => ctx.reply("⛔ Aborted."));
556
+ } catch (err) {
557
+ const msg = err instanceof Error ? err.message : String(err);
558
+ await safeSend(() =>
559
+ ctx.reply(`❌ Failed to abort: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
560
+ );
561
+ }
562
+ }