7a3ed3253c0384566924c4e331a99e116c7248b4
- 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/ci.yml b/.github/workflows/ci.yml
2new file mode 100644
3index 0000000000000000000000000000000000000000..09704cea8708b23adfdecb7d67a3edcdbadf136f
4--- /dev/null
5+++ b/.github/workflows/ci.yml
6@@ -0,0 +1,17 @@
7+name: CI
8+
9+on:
10+ pull_request:
11+ push:
12+ branches:
13+ - main
14+
15+jobs:
16+ check:
17+ runs-on: ubuntu-latest
18+ steps:
19+ - uses: actions/checkout@v6
20+ - uses: oven-sh/setup-bun@v2
21+ - run: bun install
22+ - run: bun lint
23+ - run: bun typecheck
24diff --git a/.gitignore b/.gitignore
25new file mode 100644
26index 0000000000000000000000000000000000000000..87b2330b048843971ef95b9b75e22dda15228d57
27--- /dev/null
28+++ b/.gitignore
29@@ -0,0 +1,3 @@
30+node_modules
31+dist
32+sessions.json
33diff --git a/CLAUDE.md b/CLAUDE.md
34new file mode 100644
35index 0000000000000000000000000000000000000000..764c1dd99db169bdf77e9b7ec47283aaf3280911
36--- /dev/null
37+++ b/CLAUDE.md
38@@ -0,0 +1,106 @@
39+
40+Default to using Bun instead of Node.js.
41+
42+- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
43+- Use `bun test` instead of `jest` or `vitest`
44+- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
45+- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
46+- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
47+- Use `bunx <package> <command>` instead of `npx <package> <command>`
48+- Bun automatically loads .env, so don't use dotenv.
49+
50+## APIs
51+
52+- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
53+- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
54+- `Bun.redis` for Redis. Don't use `ioredis`.
55+- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
56+- `WebSocket` is built-in. Don't use `ws`.
57+- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
58+- Bun.$`ls` instead of execa.
59+
60+## Testing
61+
62+Use `bun test` to run tests.
63+
64+```ts#index.test.ts
65+import { test, expect } from "bun:test";
66+
67+test("hello world", () => {
68+ expect(1).toBe(1);
69+});
70+```
71+
72+## Frontend
73+
74+Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
75+
76+Server:
77+
78+```ts#index.ts
79+import index from "./index.html"
80+
81+Bun.serve({
82+ routes: {
83+ "/": index,
84+ "/api/users/:id": {
85+ GET: (req) => {
86+ return new Response(JSON.stringify({ id: req.params.id }));
87+ },
88+ },
89+ },
90+ // optional websocket support
91+ websocket: {
92+ open: (ws) => {
93+ ws.send("Hello, world!");
94+ },
95+ message: (ws, message) => {
96+ ws.send(message);
97+ },
98+ close: (ws) => {
99+ // handle close
100+ }
101+ },
102+ development: {
103+ hmr: true,
104+ console: true,
105+ }
106+})
107+```
108+
109+HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
110+
111+```html#index.html
112+<html>
113+ <body>
114+ <h1>Hello, world!</h1>
115+ <script type="module" src="./frontend.tsx"></script>
116+ </body>
117+</html>
118+```
119+
120+With the following `frontend.tsx`:
121+
122+```tsx#frontend.tsx
123+import React from "react";
124+import { createRoot } from "react-dom/client";
125+
126+// import .css files directly and it works
127+import './index.css';
128+
129+const root = createRoot(document.body);
130+
131+export default function Frontend() {
132+ return <h1>Hello, world!</h1>;
133+}
134+
135+root.render(<Frontend />);
136+```
137+
138diff --git a/LICENSE b/LICENSE
139new file mode 100644
140index 0000000000000000000000000000000000000000..2adbdbabe6ac6e70b12ad2d39b17f9c5e184c325
141--- /dev/null
142+++ b/LICENSE
143@@ -0,0 +1,21 @@
144+MIT License
145+
146+Copyright (c) 2026 TheEdgeOfRage
147+
148+Permission is hereby granted, free of charge, to any person obtaining a copy
149+of this software and associated documentation files (the "Software"), to deal
150+in the Software without restriction, including without limitation the rights
151+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
152+copies of the Software, and to permit persons to whom the Software is
153+furnished to do so, subject to the following conditions:
154+
155+The above copyright notice and this permission notice shall be included in all
156+copies or substantial portions of the Software.
157+
158+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
159+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
160+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
161+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
162+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
163+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
164+SOFTWARE.
165diff --git a/Makefile b/Makefile
166new file mode 100644
167index 0000000000000000000000000000000000000000..7f3424ec82d4a80e8ecd5a129e5b6c97ded20509
168--- /dev/null
169+++ b/Makefile
170@@ -0,0 +1,9 @@
171+.PHONY: lint typecheck check
172+
173+lint:
174+ bun run lint
175+
176+typecheck:
177+ bun run typecheck
178+
179+check: lint typecheck
180diff --git a/README.md b/README.md
181new file mode 100644
182index 0000000000000000000000000000000000000000..3ed441dd3e42dc7dfc9fda6687d5d3cb7ad1944f
183--- /dev/null
184+++ b/README.md
185@@ -0,0 +1,15 @@
186+# opencode-telegram
187+
188+To install dependencies:
189+
190+```bash
191+bun install
192+```
193+
194+To run:
195+
196+```bash
197+bun run
198+```
199+
200+This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
201diff --git a/bun.lock b/bun.lock
202new file mode 100644
203index 0000000000000000000000000000000000000000..edca7b087a6c0ec235572d96f3356869f31c7a13
204--- /dev/null
205+++ b/bun.lock
206@@ -0,0 +1,267 @@
207+{
208+ "lockfileVersion": 1,
209+ "configVersion": 1,
210+ "workspaces": {
211+ "": {
212+ "name": "opencode-telegram",
213+ "dependencies": {
214+ "@opencode-ai/plugin": "^1.3.0",
215+ "@opencode-ai/sdk": "1.3.17",
216+ "grammy": "^1.35.0",
217+ },
218+ "devDependencies": {
219+ "@eslint/js": "^9.23.0",
220+ "@types/bun": "latest",
221+ "@types/node": "^22.13.14",
222+ "eslint": "^9.23.0",
223+ "typescript": "^5.8.2",
224+ "typescript-eslint": "^8.27.0",
225+ },
226+ },
227+ },
228+ "packages": {
229diff --git a/eslint.config.js b/eslint.config.js
230new file mode 100644
231index 0000000000000000000000000000000000000000..a5b335e1b9dde851a950aa108f776be101210bc2
232--- /dev/null
233+++ b/eslint.config.js
234@@ -0,0 +1,10 @@
235+import eslint from "@eslint/js";
236+import tseslint from "typescript-eslint";
237+
238+export default tseslint.config(
239+ eslint.configs.recommended,
240+ ...tseslint.configs.recommended,
241+ {
242+ ignores: ["dist/", "node_modules/"],
243+ },
244+);
245diff --git a/package.json b/package.json
246new file mode 100644
247index 0000000000000000000000000000000000000000..d84e6b588f9ccdcc0b2ef8ca43ef3e7a1ca1c4d7
248--- /dev/null
249+++ b/package.json
250@@ -0,0 +1,23 @@
251+{
252+ "name": "opencode-telegram",
253+ "version": "0.1.0",
254+ "type": "module",
255+ "private": true,
256+ "scripts": {
257+ "typecheck": "tsc --noEmit",
258+ "lint": "eslint ."
259+ },
260+ "dependencies": {
261+ "@opencode-ai/plugin": "^1.3.0",
262+ "@opencode-ai/sdk": "1.3.17",
263+ "grammy": "^1.35.0"
264+ },
265+ "devDependencies": {
266+ "@types/node": "^22.13.14",
267+ "eslint": "^9.23.0",
268+ "@eslint/js": "^9.23.0",
269+ "typescript": "^5.8.2",
270+ "typescript-eslint": "^8.27.0",
271+ "@types/bun": "latest"
272+ }
273+}
274diff --git a/src/events.ts b/src/events.ts
275new file mode 100644
276index 0000000000000000000000000000000000000000..a88835b15bd4586aa6a01df1c3cebc6fabaf69dd
277--- /dev/null
278+++ b/src/events.ts
279@@ -0,0 +1,70 @@
280+import type { Event, EventMessageUpdated, EventMessagePartUpdated, Part } from "@opencode-ai/sdk";
281+import * as log from "./log.js";
282+
283+// Server sends `permission.asked` with this shape, which diverges from the SDK's
284+// v1 Permission type. Keep a local interface until the SDK aligns.
285+export interface PermissionEvent {
286+ id: string;
287+ sessionID: string;
288+ permission: string;
289+ patterns: string[];
290+ metadata: Record<string, unknown>;
291+ always?: string[];
292+ tool?: { messageID: string; callID: string };
293+}
294+
295+export type PartHandler = (parts: Part[]) => void;
296+export type PermissionHandler = (perm: PermissionEvent) => void;
297+
298+interface SessionHandler {
299+ onPart: PartHandler;
300+ onPermission: PermissionHandler;
301+ parts: Map<string, Part>;
302+ assistantMessageIDs: Set<string>;
303+}
304+
305+const handlers = new Map<string, SessionHandler>();
306+
307+export function registerSession(
308+ sessionId: string,
309+ onPart: PartHandler,
310+ onPermission: PermissionHandler,
311+): void {
312+ handlers.set(sessionId, { onPart, onPermission, parts: new Map(), assistantMessageIDs: new Set() });
313+}
314+
315+export function unregisterSession(sessionId: string): void {
316+ handlers.delete(sessionId);
317+}
318+
319+export function handleEvent(event: Event): void {
320+ const type = event.type as string;
321+
322+ if (type === "message.updated") {
323+ const info = (event as EventMessageUpdated).properties.info;
324+ if (info.role !== "assistant") return;
325+ const handler = handlers.get(info.sessionID);
326+ if (!handler) return;
327+ handler.assistantMessageIDs.add(info.id);
328+ return;
329+ }
330+
331+ if (type === "message.part.updated") {
332+ const part = (event as EventMessagePartUpdated).properties.part;
333+ const handler = handlers.get(part.sessionID);
334+ if (!handler) return;
335+ if (!handler.assistantMessageIDs.has(part.messageID)) return;
336+ handler.parts.set(part.id, part);
337+ handler.onPart(Array.from(handler.parts.values()));
338+ return;
339+ }
340+
341+ if (type === "permission.asked" || type === "permission.updated") {
342+ const perm = event.properties as unknown as PermissionEvent;
343+ log.info(`[events] ${type} session=${perm.sessionID} perm=${perm.id} permission=${perm.permission}`);
344+ const handler = handlers.get(perm.sessionID);
345+ if (!handler) return;
346+ handler.onPermission(perm);
347+ return;
348+ }
349+}
350diff --git a/src/format.ts b/src/format.ts
351new file mode 100644
352index 0000000000000000000000000000000000000000..407b897a99785be61dd0faccc0ebca3324b9729d
353--- /dev/null
354+++ b/src/format.ts
355@@ -0,0 +1,156 @@
356+import type { Part } from "@opencode-ai/sdk/client";
357+
358+const TELEGRAM_MAX = 4096;
359+
360+// eslint-disable-next-line no-useless-escape
361+const MDVV2_SPECIAL = /[_*\[\]()~`>#+\-=|{}.!\\]/g;
362+
363+export function escapeMarkdownV2(text: string): string {
364+ return text.replace(MDVV2_SPECIAL, "\\$&");
365+}
366+
367+/**
368+ * Escape text for MarkdownV2 but preserve code blocks (``` delimited).
369+ * Inside code blocks only ` and \ need escaping.
370+ */
371+function escapeWithCodeBlocks(text: string): string {
372+ const parts: string[] = [];
373+ let cursor = 0;
374+
375+ // Match fenced code blocks: ```lang?\n...\n```
376+ const codeBlockRe = /```[\s\S]*?```/g;
377+ let match: RegExpExecArray | null;
378+
379+ while ((match = codeBlockRe.exec(text)) !== null) {
380+ // Escape text before this code block
381+ if (match.index > cursor) {
382+ parts.push(escapeMarkdownV2(text.slice(cursor, match.index)));
383+ }
384+ // Code blocks are passed through as-is — Telegram handles them natively
385+ parts.push(match[0]);
386+ cursor = match.index + match[0].length;
387+ }
388+
389+ // Remaining text after last code block
390+ if (cursor < text.length) {
391+ parts.push(escapeMarkdownV2(text.slice(cursor)));
392+ }
393+
394+ return parts.join("");
395+}
396+
397+/** Escape text for use inside a MarkdownV2 code block (only ` and \ need escaping). */
398+function escapeCodeContent(text: string): string {
399+ return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
400+}
401+
402+function formatToolPart(part: Extract<Part, { type: "tool" }>): string {
403+ const { state } = part;
404+ const input = state.input;
405+ const target =
406+ (input["path"] as string | undefined) ??
407+ (input["filePath"] as string | undefined) ??
408+ (input["file"] as string | undefined) ??
409+ (input["command"] as string | undefined) ??
410+ "";
411+ const suffix = target ? ` (${target})` : "";
412+ const header = "Tool: " + part.tool + suffix;
413+
414+ if (state.status === "completed" && state.output) {
415+ let output = state.output;
416+ if (output.length > 3000) output = output.slice(0, 3000) + "\n... (truncated)";
417+ return "```\n" + escapeCodeContent(header + "\n" + output) + "\n```";
418+ }
419+ if (state.status === "error") {
420+ return "```\n" + escapeCodeContent(header + "\nError: " + state.error) + "\n```";
421+ }
422+ return "```\n" + escapeCodeContent(header) + "\n```";
423+}
424+
425+/** Format only text parts (no tool summaries) */
426+export function formatTextParts(parts: Part[]): string {
427+ const segments: string[] = [];
428+ for (const part of parts) {
429+ if (part.type === "text") segments.push(escapeWithCodeBlocks(part.text));
430+ }
431+ return segments.join("\n");
432+}
433+
434+/** Extract formatted text from OpenCode Part[] response */
435+export function formatParts(parts: Part[]): string {
436+ const segments: string[] = [];
437+
438+ for (const part of parts) {
439+ switch (part.type) {
440+ case "text":
441+ segments.push(escapeWithCodeBlocks(part.text));
442+ break;
443+ case "tool":
444+ segments.push(formatToolPart(part));
445+ break;
446+ // Other part types (step-start, step-finish, etc.) are ignored
447+ }
448+ }
449+
450+ return segments.join("\n");
451+}
452+
453+/**
454+ * Split a formatted message into chunks that fit within Telegram's 4096 char limit.
455diff --git a/src/log.ts b/src/log.ts
456new file mode 100644
457index 0000000000000000000000000000000000000000..f692e057641b3e6efa396686e133b2501656e16c
458--- /dev/null
459+++ b/src/log.ts
460@@ -0,0 +1,35 @@
461+import { createWriteStream, mkdirSync, type WriteStream } from "fs";
462+import { join } from "path";
463+import { homedir } from "os";
464+
465+const LOG_DIR = join(homedir(), ".local", "share", "opencode-telegram", "log");
466+
467+let stream: WriteStream;
468+
469+function getStream(): WriteStream {
470+ if (!stream) {
471+ mkdirSync(LOG_DIR, { recursive: true });
472+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
473+ stream = createWriteStream(join(LOG_DIR, `${timestamp}.log`), {
474+ flags: "a",
475+ });
476+ }
477+ return stream;
478+}
479+
480+function write(level: string, msg: string): void {
481+ const ts = new Date().toISOString();
482+ getStream().write(`${ts} ${level} ${msg}\n`);
483+}
484+
485+export function info(msg: string): void {
486+ write("INFO", msg);
487+}
488+
489+export function error(msg: string, err?: unknown): void {
490+ const suffix =
491+ err !== undefined
492+ ? ` ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`
493+ : "";
494+ write("ERROR", msg + suffix);
495+}
496diff --git a/src/main.ts b/src/main.ts
497new file mode 100644
498index 0000000000000000000000000000000000000000..72704a2fd9fd06542ae10224ed2aa1aee6611791
499--- /dev/null
500+++ b/src/main.ts
501@@ -0,0 +1,93 @@
502+import type { Plugin } from "@opencode-ai/plugin";
503+import { tool } from "@opencode-ai/plugin";
504+import type { Bot } from "grammy";
505+import { readFileSync } from "node:fs";
506+import { join } from "node:path";
507+import { homedir } from "node:os";
508+import * as log from "./log.js";
509+import { init, setAttachedSession, clearAttachedSession } from "./opencode.js";
510+import { handleEvent } from "./events.js";
511+import { BOT_COMMANDS, createBot } from "./telegram.js";
512+
513+interface Config {
514+ token: string;
515+ allowedUsers: number[];
516+}
517+
518+function loadConfig(): Config {
519+ const configPath = join(homedir(), ".config", "opencode", "telegram.json");
520+ let raw: string;
521+ try {
522+ raw = readFileSync(configPath, "utf-8");
523+ } catch {
524+ throw new Error(`cannot read config: ${configPath}`);
525+ }
526+ const json = JSON.parse(raw) as Record<string, unknown>;
527+ const token = json.token;
528+ if (typeof token !== "string" || !token) {
529+ throw new Error(`"token" missing in ${configPath}`);
530+ }
531+ const users = json.allowedUsers;
532+ if (!Array.isArray(users) || users.length === 0) {
533+ throw new Error(`"allowedUsers" must be a non-empty array in ${configPath}`);
534+ }
535+ return { token, allowedUsers: users.map(Number) };
536+}
537+
538+export const OpenCodeTelegram: Plugin = async ({ client, directory }) => {
539+ const { token, allowedUsers } = loadConfig();
540+
541+ init(client, directory);
542+
543+ let bot: Bot | null = null;
544+ const autoConnect = process.env.TELEGRAM_AUTOCONNECT === "1";
545+
546+ async function connect(): Promise<string> {
547+ if (bot) return "already connected";
548+ bot = createBot(token!, allowedUsers);
549+ await bot.api.setMyCommands(BOT_COMMANDS);
550+ log.info("telegram bot starting long-polling...");
551+ bot.start();
552+ return "connected";
553+ }
554+
555+ async function disconnect(): Promise<string> {
556+ if (!bot) return "not connected";
557+ await bot.stop();
558+ bot = null;
559+ clearAttachedSession();
560+ log.info("telegram bot stopped");
561+ return "disconnected";
562+ }
563+
564+ if (autoConnect) {
565+ connect().catch((err) => log.error("autoconnect failed:", err));
566+ }
567+
568+ return {
569+ event: async ({ event }) => {
570+ try {
571+ handleEvent(event);
572+ } catch (err) {
573+ log.error("event handler error:", err);
574+ }
575+ },
576+ tool: {
577+ telegram: tool({
578+ description:
579+ "Connect or disconnect the Telegram bot. Actions: connect, disconnect, status.",
580+ args: {
581+ action: tool.schema.enum(["connect", "disconnect", "status"]),
582+ },
583+ async execute(args, context) {
584+ if (args.action === "connect") {
585+ setAttachedSession(context.sessionID);
586+ return connect();
587+ }
588+ if (args.action === "disconnect") return disconnect();
589+ return bot ? "connected" : "disconnected";
590+ },
591+ }),
592+ },
593+ };
594+};
595diff --git a/src/opencode.ts b/src/opencode.ts
596new file mode 100644
597index 0000000000000000000000000000000000000000..855a5913961dd6d51b477aa2c4ef1a127c4f4b46
598--- /dev/null
599+++ b/src/opencode.ts
600@@ -0,0 +1,164 @@
601+import type { createOpencodeClient, Part } from "@opencode-ai/sdk";
602+import { readFileSync, writeFileSync } from "fs";
603+import { resolve } from "path";
604+
605+type Client = ReturnType<typeof createOpencodeClient>;
606+
607+let client: Client;
608+let sessionsFile: string;
609+
610+const chatSessions = new Map<number, string>();
611+const chatAgents = new Map<number, string>();
612+
613+// When set, all Telegram messages route to this session (interactive mode).
614+let attachedSession: string | null = null;
615+
616+export function setAttachedSession(sessionId: string): void {
617+ attachedSession = sessionId;
618+}
619+
620+export function clearAttachedSession(): void {
621+ attachedSession = null;
622+}
623+
624+export function init(c: Client, directory: string): void {
625+ client = c;
626+ sessionsFile = resolve(directory, ".opencode", "sessions.json");
627+ loadSessions();
628+}
629+
630+function loadSessions(): void {
631+ try {
632+ const raw = readFileSync(sessionsFile, "utf8");
633+ const obj = JSON.parse(raw) as Record<string, string>;
634+ for (const [k, v] of Object.entries(obj)) chatSessions.set(Number(k), v);
635+ } catch {
636+ // no file or invalid — start fresh
637+ }
638+}
639+
640+function saveSessions(): void {
641+ const obj: Record<string, string> = {};
642+ for (const [k, v] of chatSessions) obj[String(k)] = v;
643+ writeFileSync(sessionsFile, JSON.stringify(obj, null, 2));
644+}
645+
646+export async function getOrCreateSession(
647+ chatId: number,
648+): Promise<{ sessionId: string; fallback: boolean }> {
649+ const target = attachedSession ?? chatSessions.get(chatId);
650+ if (target) {
651+ const list = await client.session.list();
652+ if (!list.error && list.data?.some((s) => s.id === target)) {
653+ return { sessionId: target, fallback: false };
654+ }
655+ // Attached session gone — clear and fall through
656+ if (attachedSession) attachedSession = null;
657+ }
658+
659+ const result = await client.session.create();
660+ if (result.error) throw new Error(`failed to create session: ${result.error}`);
661+
662+ const sessionId = result.data.id;
663+ chatSessions.set(chatId, sessionId);
664+ saveSessions();
665+ return { sessionId, fallback: target !== undefined };
666+}
667+
668+export async function listAgents(): Promise<string[]> {
669+ const result = await client.app.agents();
670+ if (result.error) throw new Error(`list agents failed: ${JSON.stringify(result.error)}`);
671+ if (!result.data) return [];
672+ return result.data.map((a) => a.name);
673+}
674+
675+export function getAgent(chatId: number): string | undefined {
676+ return chatAgents.get(chatId);
677+}
678+
679+export function setAgent(chatId: number, agent: string): void {
680+ chatAgents.set(chatId, agent);
681+}
682+
683+export async function sendPrompt(
684+ sessionId: string,
685+ text: string,
686+ agent?: string,
687+): Promise<Part[]> {
688+ const result = await client.session.prompt({
689+ path: { id: sessionId },
690+ body: { parts: [{ type: "text", text }], ...(agent ? { agent } : {}) },
691+ });
692+ if (result.error) throw new Error(`prompt failed: ${JSON.stringify(result.error)}`);
693+
694+ return result.data.parts;
695+}
696+
697+export async function createNewSession(chatId: number): Promise<string> {
698+ const result = await client.session.create();
699+ if (result.error) throw new Error(`failed to create session: ${result.error}`);
700diff --git a/src/telegram.ts b/src/telegram.ts
701new file mode 100644
702index 0000000000000000000000000000000000000000..3dd876f7949c868b052b02120a212f7502886f1d
703--- /dev/null
704+++ b/src/telegram.ts
705@@ -0,0 +1,404 @@
706+import { Bot, InlineKeyboard, type Context } from "grammy";
707+import type { Chat } from "grammy/types";
708+import type { Part } from "@opencode-ai/sdk";
709+import * as log from "./log.js";
710+import type { PermissionEvent } from "./events.js";
711+import { escapeMarkdownV2, formatParts, formatTextParts, splitMessage } from "./format.js";
712+import {
713+ getOrCreateSession,
714+ createNewSession,
715+ listSessions,
716+ getSessionId,
717+ switchSession,
718+ getSessionMessages,
719+ abortSession,
720+ sendPrompt,
721+ replyPermission,
722+ listAgents,
723+ getAgent,
724+ setAgent,
725+} from "./opencode.js";
726+import { registerSession, unregisterSession } from "./events.js";
727+
728+export const BOT_COMMANDS = [
729+ { command: "new", description: "New session" },
730+ { command: "sessions", description: "List and switch sessions" },
731+ { command: "abort", description: "Abort current session" },
732+ { command: "history", description: "Recent messages from current session" },
733+ { command: "agent", description: "Switch agent (/agent <name>)" },
734+];
735+
736+function isChannel(chat: Chat): boolean {
737+ return chat.type === "channel" || chat.type === "supergroup";
738+}
739+
740+function formatAsQuote(text: string): string {
741+ const escaped = escapeMarkdownV2(text);
742+ const lines = escaped.split("\n");
743+ const quoted = lines.map((line) => `> ${line}`).join("\n");
744+ return quoted;
745+}
746+
747+const THROTTLE_MS = 2000;
748+
749+// Track channel membership for quoting
750+let channelChatId: number | null = null;
751+
752+// Telegram callback data is limited to 64 bytes. Permission IDs are too long,
753+// so we store them in a map keyed by a short incrementing counter.
754+let permCounter = 0;
755+const pendingPerms = new Map<string, { sessionId: string; permissionId: string }>();
756+
757+function formatPartsPreview(parts: Part[]): string {
758+ const text = formatParts(parts);
759+ if (!text) return escapeMarkdownV2("thinking...");
760+ // Truncate to fit Telegram's 4096 limit for edits
761+ if (text.length > 4000) return text.slice(0, 4000) + escapeMarkdownV2("...");
762+ return text;
763+}
764+
765+async function editMessage(
766+ ctx: Context,
767+ messageId: number,
768+ text: string,
769+): Promise<void> {
770+ try {
771+ await ctx.api.editMessageText(ctx.chat!.id, messageId, text, {
772+ parse_mode: "MarkdownV2",
773+ });
774+ } catch {
775+ // Edit may fail if text unchanged or message deleted — ignore
776+ }
777+}
778+
779+function formatPermissionMessage(perm: PermissionEvent): string {
780+ const lines = [escapeMarkdownV2(`Permission: ${perm.permission}`)];
781+ if (perm.patterns.length > 0) {
782+ lines.push(escapeMarkdownV2(perm.patterns.join("\n")));
783+ }
784+ return lines.join("\n");
785+}
786+
787+function trackChannel(ctx: Context): void {
788+ const chat = ctx.chat;
789+ if (!chat) return;
790+ if (isChannel(chat)) {
791+ channelChatId = chat.id;
792+ }
793+}
794+
795+export function createBot(token: string, allowedUsers: number[]): Bot {
796+ const bot = new Bot(token);
797+
798+ const allowed = new Set(allowedUsers);
799+
800+ // Auth middleware — reject users not in allowlist
801+ bot.use(async (ctx, next) => {
802+ const userId = ctx.from?.id;
803+ if (!userId || !allowed.has(userId)) {
804+ await ctx.reply("Not authorized.");
805diff --git a/tsconfig.json b/tsconfig.json
806new file mode 100644
807index 0000000000000000000000000000000000000000..f9ef6afd8702d380b9ddf87d4a9ff0afb1c9f8da
808--- /dev/null
809+++ b/tsconfig.json
810@@ -0,0 +1,18 @@
811+{
812+ "compilerOptions": {
813+ "target": "ESNext",
814+ "module": "NodeNext",
815+ "moduleResolution": "NodeNext",
816+ "strict": true,
817+ "esModuleInterop": true,
818+ "skipLibCheck": true,
819+ "outDir": "dist",
820+ "rootDir": "src",
821+ "declaration": true,
822+ "resolveJsonModule": true,
823+ "isolatedModules": true,
824+ "noUncheckedIndexedAccess": true
825+ },
826+ "include": ["src"],
827+ "exclude": ["node_modules", "dist"]
828+}