7a3ed3253c0384566924c4e331a99e116c7248b4

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Import telegram plugin from clawcode

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+}