43967f7a07b004cdaaeebd303ff32e705e3d0f92
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
2index 2a75edab5560e28328e5fe11ab1ea428405da9d3..a1ab12e723131d73495d411ef8dd683f872ff59c 100644
3--- a/.github/workflows/publish.yml
4+++ b/.github/workflows/publish.yml
5@@ -19,6 +19,6 @@ jobs:
6 node-version: "24"
7 registry-url: "https://registry.npmjs.org"
8 - run: bun install
9- - run: bun run build
10+ - run: bun run typecheck
11 - run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
12 - run: npm publish --access public
13diff --git a/.gitignore b/.gitignore
14index 87b2330b048843971ef95b9b75e22dda15228d57..f06235c460c2de3d97efc8371125594b7b761b08 100644
15--- a/.gitignore
16+++ b/.gitignore
17@@ -1,3 +1,2 @@
18 node_modules
19 dist
20-sessions.json
21diff --git a/README.md b/README.md
22index 210c939e95cef81e79b229cb8abe5a036511b830..aa2e8e5b7ceda514600a57db0a3ad6d8aabaae28 100644
23--- a/README.md
24+++ b/README.md
25@@ -1,65 +1,100 @@
26-# opencode-telegram
27+# pi-telegram
28
29-A Telegram bot plugin for [opencode](https://github.com/opencode-ai/opencode) that bridges Telegram chats to opencode. Send prompts and receive streamed responses directly in Telegram.
30+A [pi](https://github.com/badlogic/pi-mono) extension that bridges your pi coding agent session to Telegram. Send prompts from Telegram and receive streaming responses with tool execution previews.
31
32 ## Features
33
34-- **Streaming responses** — responses are streamed with real-time edit-in-place updates
35-- **Session management** — create, list, and switch between opencode sessions
36-- **Agent switching** — list available agents or switch to a specific one
37-- **Permission handling** — inline keyboard buttons to allow/deny tool execution requests
38-- **Tool output formatting** — tool calls rendered as code blocks with name, target, and truncated output
39-- **Message splitting** — long responses are split respecting Telegram's 4096-char limit and code block boundaries
40-- **Auth** — only allowed Telegram user IDs can interact with the bot
41+- **Remote access** — interact with your pi session from Telegram
42+- **Streaming responses** — see the assistant's reply update in real time
43+- **Tool execution tracking** — tool calls and results shown as code blocks
44+- **Channel support** — works in channels/supergroups (responses are quoted)
45+- **Access control** — allowlist of Telegram user IDs
46+- **Autoconnect** — optionally connect on startup via env var
47
48-### Bot commands
49+## Setup
50
51-| Command | Description |
52-| --------------- | ------------------------------------------------------------- |
53-| `/new` | Create a new opencode session |
54-| `/sessions` | List all sessions with inline keyboard to switch between them |
55-| `/abort` | Abort the current in-flight prompt |
56-| `/agent [name]` | List available agents or switch to a specific one |
57-| _(text)_ | Send as a prompt to the active opencode session |
58+### 1. Install the extension
59
60-## Installation
61+```bash
62+pi install npm:@theedgeofrage/pi-telegram
63+```
64
65-Add the plugin to your opencode config (`~/.config/opencode/config.json`):
66+Or for development, clone and load locally:
67
68-```json
69-{
70- "plugin": ["@theedgeofrage/opencode-telegram@latest"]
71-}
72+```bash
73+pi -e /path/to/pi-telegram
74 ```
75
76-## Configuration
77+### 2. Create a Telegram bot
78
79-Create `~/.config/opencode/telegram.json`:
80+Talk to [@BotFather](https://t.me/BotFather) on Telegram and create a new bot. Save the token.
81+
82+### 3. Configure
83+
84+Create `~/.config/pi/telegram.json`:
85
86 ```json
87 {
88- "token": "<telegram-bot-token>",
89+ "token": "YOUR_BOT_TOKEN",
90 "allowedUsers": [123456789]
91 }
92 ```
93
94-- `token` — Telegram Bot API token from [@BotFather](https://t.me/BotFather)
95-- `allowedUsers` — array of numeric Telegram user IDs authorized to use the bot
96+Find your Telegram user ID by messaging [@userinfobot](https://t.me/userinfobot).
97+
98+### 4. Connect
99+
100+From pi's TUI, use the command:
101+
102+```
103+/telegram connect
104+```
105+
106+Or set `TELEGRAM_AUTOCONNECT=1` in your environment to connect on startup.
107
108 ## Usage
109
110-Once installed and configured, ask opencode to connect to Telegram:
111+### pi commands
112+
113+| Command | Description |
114+| --------------------------------- | ---------------------- |
115+| `/telegram connect` | Start the Telegram bot |
116+| `/telegram disconnect` | Stop the Telegram bot |
117+| `/telegram` or `/telegram status` | Show connection status |
118
119-> Connect telegram
120+### Telegram bot commands
121
122-This starts the bot and attaches it to the current opencode session. All messages sent to the bot in Telegram will be routed to this session.
123+| Command | Description |
124+| -------- | --------------------------- |
125diff --git a/bun.lock b/bun.lock
126index cfb9dcec35b4d0bfe5d22d86e54126253736c02c..01ad0a7af44d60a1dbcb3627701b58dad667bba7 100644
127--- a/bun.lock
128+++ b/bun.lock
129@@ -3,14 +3,15 @@
130 "configVersion": 1,
131 "workspaces": {
132 "": {
133- "name": "opencode-telegram",
134+ "name": "@theedgeofrage/pi-telegram",
135 "dependencies": {
136- "@opencode-ai/plugin": "^1.4.0",
137- "@opencode-ai/sdk": "^1.4.0",
138 "grammy": "^1.35.0",
139 },
140 "devDependencies": {
141 "@eslint/js": "^9.23.0",
142+ "@mariozechner/pi-ai": "^0.67.6",
143+ "@mariozechner/pi-coding-agent": "^0.67.6",
144+ "@sinclair/typebox": "^0.34.0",
145 "@types/bun": "latest",
146 "@types/node": "^22.13.14",
147 "eslint": "^9.23.0",
148@@ -18,9 +19,86 @@
149 "typescript": "^5.8.2",
150 "typescript-eslint": "^8.27.0",
151 },
152+ "peerDependencies": {
153+ "@mariozechner/pi-ai": "*",
154+ "@mariozechner/pi-coding-agent": "*",
155+ "@sinclair/typebox": "*",
156+ },
157 },
158 },
159 "packages": {
160diff --git a/package.json b/package.json
161index 061cdad3a6c7ef6c919904c32d8c45226762dd82..9adb8e5ec66df96740cc8102c270f5da9826d0d0 100644
162--- a/package.json
163+++ b/package.json
164@@ -1,31 +1,41 @@
165 {
166- "name": "@theedgeofrage/opencode-telegram",
167+ "name": "@theedgeofrage/pi-telegram",
168 "version": "0.1.0",
169 "type": "module",
170 "repository": {
171 "type": "git",
172 "url": "https://github.com/TheEdgeOfRage/opencode-telegram"
173 },
174- "main": "dist/main.js",
175- "types": "dist/main.d.ts",
176+ "keywords": [
177+ "pi-package"
178+ ],
179+ "pi": {
180+ "extensions": [
181+ "./src/index.ts"
182+ ]
183+ },
184 "files": [
185- "dist"
186+ "src"
187 ],
188 "scripts": {
189- "build": "tsc",
190 "typecheck": "tsc --noEmit",
191 "lint": "eslint .",
192 "format": "prettier --write .",
193- "format:check": "prettier --check .",
194- "prepublishOnly": "bun run build"
195+ "format:check": "prettier --check ."
196 },
197 "dependencies": {
198- "@opencode-ai/plugin": "^1.4.0",
199- "@opencode-ai/sdk": "^1.4.0",
200 "grammy": "^1.35.0"
201 },
202+ "peerDependencies": {
203+ "@mariozechner/pi-coding-agent": "*",
204+ "@mariozechner/pi-ai": "*",
205+ "@sinclair/typebox": "*"
206+ },
207 "devDependencies": {
208 "@eslint/js": "^9.23.0",
209+ "@mariozechner/pi-coding-agent": "^0.67.6",
210+ "@mariozechner/pi-ai": "^0.67.6",
211+ "@sinclair/typebox": "^0.34.0",
212 "@types/bun": "latest",
213 "@types/node": "^22.13.14",
214 "eslint": "^9.23.0",
215diff --git a/src/events.ts b/src/events.ts
216deleted file mode 100644
217index 2ac3c9bd5375522d1f1081d3292d951b30fa1f08..0000000000000000000000000000000000000000
218--- a/src/events.ts
219+++ /dev/null
220@@ -1,82 +0,0 @@
221-import type {
222- Event,
223- EventMessageUpdated,
224- EventMessagePartUpdated,
225- Part,
226-} from "@opencode-ai/sdk";
227-import * as log from "./log.js";
228-
229-// Server sends `permission.asked` with this shape, which diverges from the SDK's
230-// v1 Permission type. Keep a local interface until the SDK aligns.
231-export interface PermissionEvent {
232- id: string;
233- sessionID: string;
234- permission: string;
235- patterns: string[];
236- metadata: Record<string, unknown>;
237- always?: string[];
238- tool?: { messageID: string; callID: string };
239-}
240-
241-export type PartHandler = (parts: Part[]) => void;
242-export type PermissionHandler = (perm: PermissionEvent) => void;
243-
244-interface SessionHandler {
245- onPart: PartHandler;
246- onPermission: PermissionHandler;
247- parts: Map<string, Part>;
248- assistantMessageIDs: Set<string>;
249-}
250-
251-const handlers = new Map<string, SessionHandler>();
252-
253-export function registerSession(
254- sessionId: string,
255- onPart: PartHandler,
256- onPermission: PermissionHandler,
257-): void {
258- handlers.set(sessionId, {
259- onPart,
260- onPermission,
261- parts: new Map(),
262- assistantMessageIDs: new Set(),
263- });
264-}
265-
266-export function unregisterSession(sessionId: string): void {
267- handlers.delete(sessionId);
268-}
269-
270-export function handleEvent(event: Event): void {
271- const type = event.type as string;
272-
273- if (type === "message.updated") {
274- const info = (event as EventMessageUpdated).properties.info;
275- if (info.role !== "assistant") return;
276- const handler = handlers.get(info.sessionID);
277- if (!handler) return;
278- handler.assistantMessageIDs.add(info.id);
279- return;
280- }
281-
282- if (type === "message.part.updated") {
283- const part = (event as EventMessagePartUpdated).properties.part;
284- const handler = handlers.get(part.sessionID);
285- if (!handler) return;
286- if (!handler.assistantMessageIDs.has(part.messageID)) return;
287- handler.parts.set(part.id, part);
288- handler.onPart(Array.from(handler.parts.values()));
289- return;
290- }
291-
292- if (type === "permission.asked" || type === "permission.updated") {
293- const perm = event.properties as unknown as PermissionEvent;
294- log.info(
295- `[events] ${type} session=${perm.sessionID} perm=${perm.id} permission=${perm.permission}`,
296- );
297- const handler = handlers.get(perm.sessionID);
298- if (!handler) return;
299- handler.onPermission(perm);
300- return;
301- }
302-}
303diff --git a/src/format.ts b/src/format.ts
304index e8096257f402dbcc901dc8a240df5f1704272cb3..ebd5765438b3c7456da05917b95d0ace63bd9335 100644
305--- a/src/format.ts
306+++ b/src/format.ts
307@@ -1,5 +1,3 @@
308-import type { Part } from "@opencode-ai/sdk/client";
309-
310 const TELEGRAM_MAX = 4096;
311
312 // eslint-disable-next-line no-useless-escape
313@@ -13,7 +11,7 @@ export function escapeMarkdownV2(text: string): string {
314 * Escape text for MarkdownV2 but preserve code blocks (``` delimited).
315 * Inside code blocks only ` and \ need escaping.
316 */
317-function escapeWithCodeBlocks(text: string): string {
318+export function escapeWithCodeBlocks(text: string): string {
319 const parts: string[] = [];
320 let cursor = 0;
321
322@@ -44,60 +42,44 @@ function escapeCodeContent(text: string): string {
323 return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
324 }
325
326-function formatToolPart(part: Extract<Part, { type: "tool" }>): string {
327- const { state } = part;
328- const input = state.input;
329+export function formatAsQuote(text: string): string {
330+ const lines = text.split("\n");
331+ return lines.map((line) => `> ${line}`).join("\n");
332+}
333+
334+/** Format a tool execution as a MarkdownV2 code block */
335+export function formatToolBlock(
336+ toolName: string,
337+ args: Record<string, unknown>,
338+ status: string,
339+ result?: string,
340+ error?: string,
341+): string {
342 const target =
343- (input["path"] as string | undefined) ??
344- (input["filePath"] as string | undefined) ??
345- (input["file"] as string | undefined) ??
346- (input["command"] as string | undefined) ??
347+ (args["path"] as string | undefined) ??
348+ (args["filePath"] as string | undefined) ??
349+ (args["file"] as string | undefined) ??
350+ (args["command"] as string | undefined) ??
351 "";
352 const suffix = target ? ` (${target})` : "";
353- const header = "Tool: " + part.tool + suffix;
354+ const header = "Tool: " + toolName + suffix;
355
356- if (state.status === "completed" && state.output) {
357- let output = state.output;
358+ if (status === "completed" && result) {
359+ let output = result;
360 if (output.length > 3000)
361 output = output.slice(0, 3000) + "\n... (truncated)";
362 return "```\n" + escapeCodeContent(header + "\n" + output) + "\n```";
363 }
364- if (state.status === "error") {
365+ if (status === "error") {
366 return (
367- "```\n" + escapeCodeContent(header + "\nError: " + state.error) + "\n```"
368+ "```\n" +
369+ escapeCodeContent(header + "\nError: " + (error || "unknown")) +
370+ "\n```"
371 );
372 }
373 return "```\n" + escapeCodeContent(header) + "\n```";
374 }
375
376-/** Format only text parts (no tool summaries) */
377-export function formatTextParts(parts: Part[]): string {
378- const segments: string[] = [];
379- for (const part of parts) {
380- if (part.type === "text") segments.push(escapeWithCodeBlocks(part.text));
381- }
382- return segments.join("\n");
383-}
384-
385-/** Extract formatted text from OpenCode Part[] response */
386-export function formatParts(parts: Part[]): string {
387- const segments: string[] = [];
388-
389- for (const part of parts) {
390- switch (part.type) {
391- case "text":
392- segments.push(escapeWithCodeBlocks(part.text));
393- break;
394- case "tool":
395- segments.push(formatToolPart(part));
396- break;
397- // Other part types (step-start, step-finish, etc.) are ignored
398- }
399- }
400-
401- return segments.join("\n");
402-}
403-
404 /**
405 * Split a formatted message into chunks that fit within Telegram's 4096 char limit.
406 * Respects code block boundaries — won't split inside a fenced block.
407diff --git a/src/index.ts b/src/index.ts
408new file mode 100644
409index 0000000000000000000000000000000000000000..72d75aa62718be4b5205758616e3a2314264d44d
410--- /dev/null
411+++ b/src/index.ts
412@@ -0,0 +1,445 @@
413+import type {
414+ ExtensionAPI,
415+ ExtensionContext,
416+} from "@mariozechner/pi-coding-agent";
417+import { StringEnum, Type } from "@mariozechner/pi-ai";
418+import type { Bot, Context as GrammyContext } from "grammy";
419+import { readFileSync } from "node:fs";
420+import { join } from "node:path";
421+import { homedir } from "node:os";
422+import * as log from "./log.js";
423+import { createBot, BOT_COMMANDS } from "./telegram.js";
424+import {
425+ escapeMarkdownV2,
426+ escapeWithCodeBlocks,
427+ formatAsQuote,
428+ formatToolBlock,
429+ splitMessage,
430+} from "./format.js";
431+
432+interface Config {
433+ token: string;
434+ allowedUsers: number[];
435+}
436+
437+interface ToolExecution {
438+ toolCallId: string;
439+ toolName: string;
440+ args: Record<string, unknown>;
441+ status: "running" | "completed" | "error";
442+ result?: string;
443+ error?: string;
444+}
445+
446+interface PendingRequest {
447+ chatId: number;
448+ api: GrammyContext["api"];
449+ responseMsgId: number;
450+ lastEditTime: number;
451+ editTimer: ReturnType<typeof setTimeout> | null;
452+ typingInterval: ReturnType<typeof setInterval>;
453+ currentTurnText: string;
454+ previousTurnsText: string[];
455+ toolExecutions: ToolExecution[];
456+ isChannel: boolean;
457+}
458+
459+function loadConfig(): Config {
460+ const configPath = join(homedir(), ".config", "pi", "telegram.json");
461+ let raw: string;
462+ try {
463+ raw = readFileSync(configPath, "utf-8");
464+ } catch {
465+ throw new Error(`cannot read config: ${configPath}`);
466+ }
467+ const json = JSON.parse(raw) as Record<string, unknown>;
468+ const token = json.token;
469+ if (typeof token !== "string" || !token) {
470+ throw new Error(`"token" missing in ${configPath}`);
471+ }
472+ const users = json.allowedUsers;
473+ if (!Array.isArray(users) || users.length === 0) {
474+ throw new Error(
475+ `"allowedUsers" must be a non-empty array in ${configPath}`,
476+ );
477+ }
478+ return { token, allowedUsers: users.map(Number) };
479+}
480+
481+const THROTTLE_MS = 2000;
482+
483+export default function (pi: ExtensionAPI) {
484+ let config: Config | null = null;
485+ let configError: string | null = null;
486+ try {
487+ config = loadConfig();
488+ } catch (err) {
489+ configError = String(err instanceof Error ? err.message : err);
490+ log.error("config load failed:", configError);
491+ }
492+
493+ let bot: Bot | null = null;
494+ let pending: PendingRequest | null = null;
495+ let agentBusy = false;
496+ let currentCtx: ExtensionContext | undefined;
497+
498+ const autoConnect = process.env.TELEGRAM_AUTOCONNECT === "1";
499+
500+ // --- Helpers ---
501+
502+ function getToolResultText(result: unknown): string {
503+ if (!result || typeof result !== "object") return "";
504+ const r = result as { content?: Array<{ type: string; text?: string }> };
505+ if (!Array.isArray(r.content)) return "";
506+ return r.content
507+ .filter((c) => c.type === "text" && c.text)
508+ .map((c) => c.text!)
509+ .join("\n");
510+ }
511+
512diff --git a/src/log.ts b/src/log.ts
513index f692e057641b3e6efa396686e133b2501656e16c..9e875c88f91451f0040454db635e820ecea42141 100644
514--- a/src/log.ts
515+++ b/src/log.ts
516@@ -2,7 +2,7 @@ import { createWriteStream, mkdirSync, type WriteStream } from "fs";
517 import { join } from "path";
518 import { homedir } from "os";
519
520-const LOG_DIR = join(homedir(), ".local", "share", "opencode-telegram", "log");
521+const LOG_DIR = join(homedir(), ".local", "share", "pi-telegram", "log");
522
523 let stream: WriteStream;
524
525diff --git a/src/main.ts b/src/main.ts
526deleted file mode 100644
527index 6f8403c1c0e7db1d8e3519a09a2cd6abf396965b..0000000000000000000000000000000000000000
528--- a/src/main.ts
529+++ /dev/null
530@@ -1,103 +0,0 @@
531-import type { Plugin } from "@opencode-ai/plugin";
532-import { tool } from "@opencode-ai/plugin";
533-import type { Bot } from "grammy";
534-import { readFileSync } from "node:fs";
535-import { join } from "node:path";
536-import { homedir } from "node:os";
537-import * as log from "./log.js";
538-import { init, setAttachedSession, clearAttachedSession } from "./opencode.js";
539-import { handleEvent } from "./events.js";
540-import { BOT_COMMANDS, createBot } from "./telegram.js";
541-
542-interface Config {
543- token: string;
544- allowedUsers: number[];
545-}
546-
547-function loadConfig(): Config {
548- const configPath = join(homedir(), ".config", "opencode", "telegram.json");
549- let raw: string;
550- try {
551- raw = readFileSync(configPath, "utf-8");
552- } catch {
553- throw new Error(`cannot read config: ${configPath}`);
554- }
555- const json = JSON.parse(raw) as Record<string, unknown>;
556- const token = json.token;
557- if (typeof token !== "string" || !token) {
558- throw new Error(`"token" missing in ${configPath}`);
559- }
560- const users = json.allowedUsers;
561- if (!Array.isArray(users) || users.length === 0) {
562- throw new Error(
563- `"allowedUsers" must be a non-empty array in ${configPath}`,
564- );
565- }
566- return { token, allowedUsers: users.map(Number) };
567-}
568-
569-export const OpenCodeTelegram: Plugin = async ({ client, directory }) => {
570- init(client, directory);
571-
572- let config: Config | null = null;
573- let configError: string | null = null;
574- try {
575- config = loadConfig();
576- } catch (err) {
577- configError = String(err instanceof Error ? err.message : err);
578- log.error("config load failed:", configError);
579- }
580-
581- let bot: Bot | null = null;
582- const autoConnect = process.env.TELEGRAM_AUTOCONNECT === "1";
583-
584- async function connect(): Promise<string> {
585- if (!config) return `error: ${configError}`;
586- if (bot) return "already connected";
587- bot = createBot(config.token, config.allowedUsers);
588- await bot.api.setMyCommands(BOT_COMMANDS);
589- log.info("telegram bot starting long-polling...");
590- bot.start();
591- return "connected";
592- }
593-
594- async function disconnect(): Promise<string> {
595- if (!bot) return "not connected";
596- await bot.stop();
597- bot = null;
598- clearAttachedSession();
599- log.info("telegram bot stopped");
600- return "disconnected";
601- }
602-
603- if (autoConnect) {
604- connect().catch((err) => log.error("autoconnect failed:", err));
605- }
606-
607- return {
608- event: async ({ event }) => {
609- try {
610- handleEvent(event);
611- } catch (err) {
612- log.error("event handler error:", err);
613- }
614- },
615- tool: {
616- telegram: tool({
617- description:
618- "Connect or disconnect the Telegram bot. Actions: connect, disconnect, status.",
619- args: {
620- action: tool.schema.enum(["connect", "disconnect", "status"]),
621- },
622- async execute(args, context) {
623- if (args.action === "connect") {
624- setAttachedSession(context.sessionID);
625- return connect();
626- }
627- if (args.action === "disconnect") return disconnect();
628- return bot ? "connected" : "disconnected";
629- },
630diff --git a/src/opencode.ts b/src/opencode.ts
631deleted file mode 100644
632index f3175f7bcb93f9d5d441dbe6b9bffb74747074c1..0000000000000000000000000000000000000000
633--- a/src/opencode.ts
634+++ /dev/null
635@@ -1,153 +0,0 @@
636-import type { createOpencodeClient, Part } from "@opencode-ai/sdk";
637-import { readFileSync, writeFileSync } from "fs";
638-import { resolve } from "path";
639-
640-type Client = ReturnType<typeof createOpencodeClient>;
641-
642-let client: Client;
643-let sessionsFile: string;
644-
645-const chatSessions = new Map<number, string>();
646-const chatAgents = new Map<number, string>();
647-
648-// When set, all Telegram messages route to this session (interactive mode).
649-let attachedSession: string | null = null;
650-
651-export function setAttachedSession(sessionId: string): void {
652- attachedSession = sessionId;
653-}
654-
655-export function clearAttachedSession(): void {
656- attachedSession = null;
657-}
658-
659-export function init(c: Client, directory: string): void {
660- client = c;
661- sessionsFile = resolve(directory, ".opencode", "sessions.json");
662- loadSessions();
663-}
664-
665-function loadSessions(): void {
666- try {
667- const raw = readFileSync(sessionsFile, "utf8");
668- const obj = JSON.parse(raw) as Record<string, string>;
669- for (const [k, v] of Object.entries(obj)) chatSessions.set(Number(k), v);
670- } catch {
671- // no file or invalid — start fresh
672- }
673-}
674-
675-function saveSessions(): void {
676- const obj: Record<string, string> = {};
677- for (const [k, v] of chatSessions) obj[String(k)] = v;
678- writeFileSync(sessionsFile, JSON.stringify(obj, null, 2));
679-}
680-
681-export async function getOrCreateSession(
682- chatId: number,
683-): Promise<{ sessionId: string; fallback: boolean }> {
684- const target = attachedSession ?? chatSessions.get(chatId);
685- if (target) {
686- const list = await client.session.list();
687- if (!list.error && list.data?.some((s) => s.id === target)) {
688- return { sessionId: target, fallback: false };
689- }
690- // Attached session gone — clear and fall through
691- if (attachedSession) attachedSession = null;
692- }
693-
694- const result = await client.session.create();
695- if (result.error)
696- throw new Error(`failed to create session: ${result.error}`);
697-
698- const sessionId = result.data.id;
699- chatSessions.set(chatId, sessionId);
700- saveSessions();
701- return { sessionId, fallback: target !== undefined };
702-}
703-
704-export async function listAgents(): Promise<string[]> {
705- const result = await client.app.agents();
706- if (result.error)
707- throw new Error(`list agents failed: ${JSON.stringify(result.error)}`);
708- if (!result.data) return [];
709- return result.data.map((a) => a.name);
710-}
711-
712-export function getAgent(chatId: number): string | undefined {
713- return chatAgents.get(chatId);
714-}
715-
716-export function setAgent(chatId: number, agent: string): void {
717- chatAgents.set(chatId, agent);
718-}
719-
720-export async function sendPrompt(
721- sessionId: string,
722- text: string,
723- agent?: string,
724-): Promise<Part[]> {
725- const result = await client.session.prompt({
726- path: { id: sessionId },
727- body: { parts: [{ type: "text", text }], ...(agent ? { agent } : {}) },
728- });
729- if (result.error)
730- throw new Error(`prompt failed: ${JSON.stringify(result.error)}`);
731-
732- return result.data.parts;
733-}
734-
735diff --git a/src/telegram.ts b/src/telegram.ts
736index e96a807edd7eac71fe35b8491aca2b36599bda45..a351467410a8d816b9ab5c34fd40b5b6617744f3 100644
737--- a/src/telegram.ts
738+++ b/src/telegram.ts
739@@ -1,101 +1,31 @@
740-import { Bot, InlineKeyboard, type Context } from "grammy";
741+import { Bot, type Context } from "grammy";
742 import type { Chat } from "grammy/types";
743-import type { Part } from "@opencode-ai/sdk";
744 import * as log from "./log.js";
745-import type { PermissionEvent } from "./events.js";
746-import {
747- escapeMarkdownV2,
748- formatParts,
749- formatTextParts,
750- splitMessage,
751-} from "./format.js";
752-import {
753- getOrCreateSession,
754- createNewSession,
755- listSessions,
756- getSessionId,
757- switchSession,
758- abortSession,
759- sendPrompt,
760- replyPermission,
761- listAgents,
762- getAgent,
763- setAgent,
764-} from "./opencode.js";
765-import { registerSession, unregisterSession } from "./events.js";
766
767 export const BOT_COMMANDS = [
768- { command: "new", description: "New session" },
769- { command: "sessions", description: "List and switch sessions" },
770- { command: "abort", description: "Abort current session" },
771- { command: "agent", description: "Switch agent (/agent <name>)" },
772+ { command: "abort", description: "Abort current operation" },
773 ];
774
775 function isChannel(chat: Chat): boolean {
776 return chat.type === "channel" || chat.type === "supergroup";
777 }
778
779-function formatAsQuote(text: string): string {
780- const escaped = escapeMarkdownV2(text);
781- const lines = escaped.split("\n");
782- const quoted = lines.map((line) => `> ${line}`).join("\n");
783- return quoted;
784-}
785-
786-const THROTTLE_MS = 2000;
787-
788-// Track channel membership for quoting
789-let channelChatId: number | null = null;
790-
791-// Telegram callback data is limited to 64 bytes. Permission IDs are too long,
792-// so we store them in a map keyed by a short incrementing counter.
793-let permCounter = 0;
794-const pendingPerms = new Map<
795- string,
796- { sessionId: string; permissionId: string }
797->();
798-
799-function formatPartsPreview(parts: Part[]): string {
800- const text = formatParts(parts);
801- if (!text) return escapeMarkdownV2("thinking...");
802- // Truncate to fit Telegram's 4096 limit for edits
803- if (text.length > 4000) return text.slice(0, 4000) + escapeMarkdownV2("...");
804- return text;
805-}
806-
807-async function editMessage(
808- ctx: Context,
809- messageId: number,
810+export type MessageHandler = (
811+ chatId: number,
812 text: string,
813-): Promise<void> {
814- try {
815- await ctx.api.editMessageText(ctx.chat!.id, messageId, text, {
816- parse_mode: "MarkdownV2",
817- });
818- } catch {
819- // Edit may fail if text unchanged or message deleted — ignore
820- }
821-}
822-
823-function formatPermissionMessage(perm: PermissionEvent): string {
824- const lines = [escapeMarkdownV2(`Permission: ${perm.permission}`)];
825- if (perm.patterns.length > 0) {
826- lines.push(escapeMarkdownV2(perm.patterns.join("\n")));
827- }
828- return lines.join("\n");
829-}
830+ ctx: Context,
831+ isChannel: boolean,
832+) => Promise<void>;
833
834-function trackChannel(ctx: Context): void {
835- const chat = ctx.chat;
836- if (!chat) return;
837- if (isChannel(chat)) {
838- channelChatId = chat.id;
839diff --git a/tsconfig.json b/tsconfig.json
840index f9ef6afd8702d380b9ddf87d4a9ff0afb1c9f8da..644e56b9c5b72bead1a2f084a6b38877f736ed4f 100644
841--- a/tsconfig.json
842+++ b/tsconfig.json
843@@ -6,12 +6,11 @@
844 "strict": true,
845 "esModuleInterop": true,
846 "skipLibCheck": true,
847- "outDir": "dist",
848 "rootDir": "src",
849- "declaration": true,
850 "resolveJsonModule": true,
851 "isolatedModules": true,
852- "noUncheckedIndexedAccess": true
853+ "noUncheckedIndexedAccess": true,
854+ "noEmit": true
855 },
856 "include": ["src"],
857 "exclude": ["node_modules", "dist"]