@pedrohnas/opencode-telegram 0.1.0 → 1.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.
Files changed (43) hide show
  1. package/.env.example +17 -0
  2. package/bunfig.toml +2 -0
  3. package/docs/PROGRESS.md +327 -0
  4. package/docs/mapping.md +326 -0
  5. package/docs/plans/phase-1.md +176 -0
  6. package/docs/plans/phase-2.md +235 -0
  7. package/docs/plans/phase-3.md +485 -0
  8. package/docs/plans/phase-4.md +566 -0
  9. package/docs/spec.md +2055 -0
  10. package/e2e/client.ts +24 -0
  11. package/e2e/helpers.ts +119 -0
  12. package/e2e/phase-0.test.ts +30 -0
  13. package/e2e/phase-1.test.ts +48 -0
  14. package/e2e/phase-2.test.ts +54 -0
  15. package/e2e/phase-3.test.ts +142 -0
  16. package/e2e/phase-4.test.ts +96 -0
  17. package/e2e/runner.ts +145 -0
  18. package/package.json +14 -12
  19. package/scripts/gen-session.ts +49 -0
  20. package/src/bot.test.ts +301 -0
  21. package/src/bot.ts +91 -0
  22. package/src/config.test.ts +130 -0
  23. package/src/config.ts +15 -0
  24. package/src/event-bus.test.ts +175 -0
  25. package/src/handlers/allowlist.test.ts +60 -0
  26. package/src/handlers/allowlist.ts +33 -0
  27. package/src/handlers/cancel.test.ts +105 -0
  28. package/src/handlers/permissions.test.ts +72 -0
  29. package/src/handlers/questions.test.ts +107 -0
  30. package/src/handlers/sessions.test.ts +479 -0
  31. package/src/handlers/sessions.ts +202 -0
  32. package/src/handlers/typing.test.ts +60 -0
  33. package/src/index.ts +26 -0
  34. package/src/pending-requests.test.ts +64 -0
  35. package/src/send/chunker.test.ts +74 -0
  36. package/src/send/draft-stream.test.ts +229 -0
  37. package/src/send/format.test.ts +143 -0
  38. package/src/send/tool-progress.test.ts +70 -0
  39. package/src/session-manager.test.ts +198 -0
  40. package/src/session-manager.ts +23 -0
  41. package/src/turn-manager.test.ts +155 -0
  42. package/src/turn-manager.ts +5 -0
  43. package/tsconfig.json +9 -0
package/docs/spec.md ADDED
@@ -0,0 +1,2055 @@
1
+ # OpenCode Telegram Bot — Full Technical Specification
2
+
3
+ Comprehensive implementation guide for a Telegram bot that interfaces with the OpenCode SDK,
4
+ modeled after the OpenClaw Telegram integration (~6,500 LOC) but using the SDK HTTP client
5
+ pattern (like the web app) instead of direct agent integration.
6
+
7
+ ---
8
+
9
+ ## 1. Architecture Overview
10
+
11
+ ### 1.1 Design Philosophy
12
+
13
+ The bot acts as a **thin Telegram ↔ OpenCode SDK bridge**:
14
+
15
+ ```
16
+ Telegram Bot API ←→ [Grammy Bot] ←→ [OpenCode SDK Client] ←→ OpenCode Server
17
+ ```
18
+
19
+ Unlike OpenClaw (which integrates directly with its own AI agent system), we delegate
20
+ **all AI work** to the OpenCode server via its HTTP SDK. The bot is responsible only for:
21
+ - Translating Telegram messages → SDK `session.prompt()` calls
22
+ - Translating SDK SSE events → Telegram responses
23
+ - Managing the lifecycle mapping between Telegram chats and OpenCode sessions
24
+
25
+ ### 1.2 Anti-Leak Architecture
26
+
27
+ Every component is designed to prevent memory leaks:
28
+
29
+ ```
30
+ ┌──────────────────────────────────────────────────────────┐
31
+ │ Grammy Bot │
32
+ │ - Stateless handlers (extract minimal data from ctx) │
33
+ │ - Never close over full Grammy Context │
34
+ └─────────────────────┬────────────────────────────────────┘
35
+
36
+ ┌─────────────────────▼────────────────────────────────────┐
37
+ │ SessionManager │
38
+ │ - LRU<chatKey, SessionEntry> with TTL (30min default) │
39
+ │ - Max entries cap (e.g. 500) │
40
+ │ - SessionEntry = { sessionId, directory, createdAt } │
41
+ │ - Eviction: remove from map only (sessions persist │
42
+ │ in OpenCode server, re-hydrate via session.list()) │
43
+ └─────────────────────┬────────────────────────────────────┘
44
+
45
+ ┌─────────────────────▼────────────────────────────────────┐
46
+ │ EventBus (Single SSE) │
47
+ │ - ONE connection to global.event() │
48
+ │ - Routes events by sessionId → chatId via SessionManager│
49
+ │ - AbortController for clean shutdown │
50
+ │ - Auto-reconnect with exponential backoff │
51
+ │ - Listeners registered with { signal } for cleanup │
52
+ └─────────────────────┬────────────────────────────────────┘
53
+
54
+ ┌─────────────────────▼────────────────────────────────────┐
55
+ │ TurnManager │
56
+ │ - 1 AbortController per active turn (sessionId → ctrl) │
57
+ │ - Tracks: draft message ref, timers │
58
+ │ - abort() on: session.idle, /cancel, error │
59
+ │ - Auto-cleanup everything on turn end │
60
+ └─────────────────────┬────────────────────────────────────┘
61
+
62
+ ┌─────────────────────▼────────────────────────────────────┐
63
+ │ ResponseSender │
64
+ │ - Stateless: receives (chatId, text, opts) │
65
+ │ - Markdown → Telegram HTML conversion │
66
+ │ - Chunking (4096 char limit) │
67
+ │ - No persistent references to sent messages │
68
+ └──────────────────────────────────────────────────────────┘
69
+ ```
70
+
71
+ **Anti-leak rules:**
72
+ 1. Every resource that opens must close — SSE, AbortController, timers
73
+ 2. No `Map` without bounds — LRU or TTL on everything
74
+ 3. Callback data encoded in strings — zero in-memory storage
75
+ 4. AbortController per turn — one `abort()` cleans all listeners + timers
76
+ 5. Closures capture primitives (chatId, sessionId) — never objects (ctx, msg)
77
+
78
+ ### 1.3 SDK Connection
79
+
80
+ ```ts
81
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
82
+
83
+ // One client per project directory
84
+ const sdk = createOpencodeClient({
85
+ baseUrl: "http://127.0.0.1:4096",
86
+ directory: "/path/to/project",
87
+ })
88
+ ```
89
+
90
+ The SDK client is stateless (HTTP). Each call includes the `x-opencode-directory` header.
91
+ No connection pooling or persistent state in the client itself.
92
+
93
+ ---
94
+
95
+ ## 2. Testing Strategy
96
+
97
+ ### 2.1 Overview
98
+
99
+ Two testing layers, both mandatory per phase:
100
+
101
+ ```
102
+ ┌────────────────────────────────────────────────────┐
103
+ │ Unit Tests (bun test) │
104
+ │ - Mocks for SDK + Telegram API │
105
+ │ - 1 test file per source file (colocated) │
106
+ │ - TDD: RED → GREEN → REFACTOR │
107
+ │ - Runs in CI, no external dependencies │
108
+ └────────────────────────────────────────────────────┘
109
+ ┌────────────────────────────────────────────────────┐
110
+ │ E2E Tests (gramjs userbot) │
111
+ │ - Real Telegram (test env) + real bot │
112
+ │ - Automated: no human interaction needed │
113
+ │ - Validates full flow per phase │
114
+ │ - Phase gate: all E2E green → next phase │
115
+ └────────────────────────────────────────────────────┘
116
+ ```
117
+
118
+ ### 2.2 TDD Workflow (AI-Optimized)
119
+
120
+ Standard TDD but optimized for AI pair programming — the RED phase
121
+ does NOT execute tests (the AI already knows they'll fail since the
122
+ implementation doesn't exist yet). This saves tokens and time.
123
+
124
+ ```
125
+ RED: Write complete test file (don't run)
126
+ GREEN: Write implementation → run bun test → pass
127
+ REFACTOR: Clean up → run bun test → still passes
128
+ ```
129
+
130
+ **Per-file cycle:**
131
+ ```
132
+ 1. Write session-manager.test.ts (RED — no execution)
133
+ 2. Write session-manager.ts (GREEN)
134
+ 3. Run bun test src/session-manager.test.ts (validate)
135
+ 4. Refactor if needed (REFACTOR)
136
+ 5. Run bun test src/session-manager.test.ts (confirm)
137
+ ```
138
+
139
+ **Execution order per phase** — bottom-up by dependency:
140
+ ```
141
+ Phase 1 example:
142
+ RED+GREEN: format.test.ts → format.ts
143
+ RED+GREEN: chunker.test.ts → chunker.ts
144
+ RED+GREEN: session-manager.test.ts → session-manager.ts
145
+ RED+GREEN: turn-manager.test.ts → turn-manager.ts
146
+ RED+GREEN: event-bus.test.ts → event-bus.ts
147
+ RED+GREEN: bot.test.ts → bot.ts
148
+ ─────────────────────────────────
149
+ bun test → all green
150
+ E2E: phase-1.test.ts → real validation
151
+ ```
152
+
153
+ ### 2.3 Test File Convention
154
+
155
+ Colocated, 1:1 with source (following OpenClaw pattern):
156
+
157
+ ```
158
+ src/
159
+ session-manager.ts
160
+ session-manager.test.ts ← same directory
161
+ turn-manager.ts
162
+ turn-manager.test.ts
163
+ event-bus.ts
164
+ event-bus.test.ts
165
+ send/
166
+ format.ts
167
+ format.test.ts
168
+ chunker.ts
169
+ chunker.test.ts
170
+ e2e/
171
+ client.ts ← gramjs userbot wrapper
172
+ helpers.ts ← sendAndWait(), clickButton(), assertReply()
173
+ runner.ts ← setup/teardown bot + client
174
+ phase-0.test.ts
175
+ phase-1.test.ts
176
+ phase-2.test.ts
177
+ ...
178
+ ```
179
+
180
+ ### 2.4 Unit Test Pattern
181
+
182
+ ```ts
183
+ import { describe, test, expect, beforeEach, mock } from "bun:test"
184
+ import { SessionManager } from "./session-manager"
185
+
186
+ // Mock SDK — minimal interface matching only what's needed
187
+ const createMockSdk = (sessionId: string) => ({
188
+ session: { create: mock(async () => ({ data: { id: sessionId } })) },
189
+ })
190
+
191
+ describe("SessionManager", () => {
192
+ let sm: SessionManager
193
+
194
+ beforeEach(() => {
195
+ sm = new SessionManager({ maxEntries: 3, ttlMs: 1000 })
196
+ })
197
+
198
+ test("getOrCreate creates new session on first access", async () => {
199
+ const sdk = createMockSdk("s1")
200
+ const entry = await sm.getOrCreate("chat:1", sdk as any)
201
+ expect(entry.sessionId).toBe("s1")
202
+ expect(sdk.session.create).toHaveBeenCalledTimes(1)
203
+ })
204
+
205
+ test("getOrCreate returns cached on second access", async () => {
206
+ const sdk = createMockSdk("s1")
207
+ await sm.getOrCreate("chat:1", sdk as any)
208
+ const entry = await sm.getOrCreate("chat:1", sdk as any)
209
+ expect(entry.sessionId).toBe("s1")
210
+ expect(sdk.session.create).toHaveBeenCalledTimes(1) // not called again
211
+ })
212
+
213
+ test("evicts oldest when maxEntries exceeded", async () => {
214
+ for (let i = 1; i <= 4; i++) {
215
+ await sm.getOrCreate(`chat:${i}`, createMockSdk(`s${i}`) as any)
216
+ }
217
+ expect(sm.get("chat:1")).toBeUndefined() // evicted
218
+ expect(sm.get("chat:4")?.sessionId).toBe("s4")
219
+ })
220
+
221
+ test("getBySessionId reverse lookup", async () => {
222
+ await sm.getOrCreate("chat:1", createMockSdk("s1") as any)
223
+ expect(sm.getBySessionId("s1")?.chatKey).toBe("chat:1")
224
+ })
225
+
226
+ test("remove clears both maps", async () => {
227
+ await sm.getOrCreate("chat:1", createMockSdk("s1") as any)
228
+ sm.remove("chat:1")
229
+ expect(sm.get("chat:1")).toBeUndefined()
230
+ expect(sm.getBySessionId("s1")).toBeUndefined()
231
+ })
232
+
233
+ test("expired entries cleaned up", async () => {
234
+ await sm.getOrCreate("chat:1", createMockSdk("s1") as any)
235
+ await new Promise(r => setTimeout(r, 1100))
236
+ sm.cleanup()
237
+ expect(sm.get("chat:1")).toBeUndefined()
238
+ })
239
+ })
240
+ ```
241
+
242
+ ### 2.5 E2E Infrastructure
243
+
244
+ **Components:**
245
+
246
+ ```
247
+ ┌─────────────────────────────┐
248
+ │ E2E Test Runner (gramjs) │ ← automated test user account
249
+ │ Sends msgs, clicks btns │
250
+ │ Verifies bot responses │
251
+ └──────────────┬──────────────┘
252
+ │ MTProto (test DC)
253
+ ┌──────────────▼──────────────┐
254
+ │ Bot (Grammy) │ ← our bot, running against test env
255
+ │ test API root │
256
+ └──────────────┬──────────────┘
257
+ │ HTTP
258
+ ┌──────────────▼──────────────┐
259
+ │ OpenCode Server │ ← real server (or mock for CI)
260
+ └──────────────────────────────┘
261
+ ```
262
+
263
+ **gramjs client wrapper (`e2e/client.ts`):**
264
+
265
+ ```ts
266
+ import { TelegramClient } from "telegram"
267
+ import { StringSession } from "telegram/sessions"
268
+
269
+ export async function createTestClient(config: {
270
+ apiId: number
271
+ apiHash: string
272
+ session: string // saved StringSession
273
+ }): Promise<TelegramClient> {
274
+ const client = new TelegramClient(
275
+ new StringSession(config.session),
276
+ config.apiId,
277
+ config.apiHash,
278
+ { connectionRetries: 3 }
279
+ )
280
+ await client.connect()
281
+ return client
282
+ }
283
+ ```
284
+
285
+ **Test helpers (`e2e/helpers.ts`):**
286
+
287
+ ```ts
288
+ export async function sendAndWait(
289
+ client: TelegramClient,
290
+ botUsername: string,
291
+ text: string,
292
+ timeoutMs = 15000,
293
+ ): Promise<Api.Message> {
294
+ const before = Date.now()
295
+ await client.sendMessage(botUsername, { message: text })
296
+
297
+ // Poll for bot reply
298
+ while (Date.now() - before < timeoutMs) {
299
+ await sleep(500)
300
+ const messages = await client.getMessages(botUsername, { limit: 1 })
301
+ const latest = messages[0]
302
+ if (latest && latest.date * 1000 > before && latest.out === false) {
303
+ return latest
304
+ }
305
+ }
306
+ throw new Error(`Bot did not reply within ${timeoutMs}ms`)
307
+ }
308
+
309
+ export async function clickInlineButton(
310
+ client: TelegramClient,
311
+ botUsername: string,
312
+ msgId: number,
313
+ buttonText: string,
314
+ ): Promise<void> {
315
+ const messages = await client.getMessages(botUsername, { ids: [msgId] })
316
+ const msg = messages[0]
317
+ const rows = msg.replyMarkup?.rows ?? []
318
+ for (const row of rows) {
319
+ for (const btn of row.buttons) {
320
+ if (btn.text.includes(buttonText)) {
321
+ await client.invoke(new Api.messages.GetBotCallbackAnswer({
322
+ peer: botUsername,
323
+ msgId,
324
+ data: btn.data,
325
+ }))
326
+ return
327
+ }
328
+ }
329
+ }
330
+ throw new Error(`Button "${buttonText}" not found`)
331
+ }
332
+
333
+ export function assertContains(msg: Api.Message, pattern: string | RegExp): void {
334
+ const text = msg.text ?? msg.message ?? ""
335
+ if (typeof pattern === "string") {
336
+ if (!text.includes(pattern)) throw new Error(`Expected "${pattern}" in: ${text}`)
337
+ } else {
338
+ if (!pattern.test(text)) throw new Error(`Expected ${pattern} in: ${text}`)
339
+ }
340
+ }
341
+
342
+ export function assertHasButtons(msg: Api.Message): void {
343
+ const rows = msg.replyMarkup?.rows ?? []
344
+ if (rows.length === 0) throw new Error("Expected inline buttons, got none")
345
+ }
346
+ ```
347
+
348
+ **Test runner (`e2e/runner.ts`):**
349
+
350
+ ```ts
351
+ import { spawn, type Subprocess } from "bun"
352
+
353
+ let botProcess: Subprocess | null = null
354
+
355
+ export async function setup() {
356
+ // Start bot as subprocess
357
+ botProcess = spawn(["bun", "run", "src/index.ts"], {
358
+ env: { ...process.env, TELEGRAM_TEST_ENV: "1" },
359
+ stdout: "pipe",
360
+ })
361
+ // Wait for bot to be ready
362
+ await waitForBotReady()
363
+ }
364
+
365
+ export async function teardown() {
366
+ botProcess?.kill()
367
+ botProcess = null
368
+ }
369
+ ```
370
+
371
+ ### 2.6 E2E Tests per Phase
372
+
373
+ **Phase 0:**
374
+ ```ts
375
+ test("bot responds to /start", async () => {
376
+ const reply = await sendAndWait(client, BOT, "/start")
377
+ assertContains(reply, /OpenCode/)
378
+ })
379
+ ```
380
+
381
+ **Phase 1:**
382
+ ```ts
383
+ test("text message gets AI response", async () => {
384
+ const reply = await sendAndWait(client, BOT, "Say the word hello", 30000)
385
+ assertContains(reply, /hello/i)
386
+ })
387
+
388
+ test("/new creates fresh session", async () => {
389
+ const reply = await sendAndWait(client, BOT, "/new")
390
+ assertContains(reply, /session/i)
391
+ })
392
+
393
+ test("long response is chunked correctly", async () => {
394
+ const reply = await sendAndWait(client, BOT, "List all US states with capitals", 30000)
395
+ // Should receive at least one message
396
+ assertContains(reply, /.+/)
397
+ })
398
+ ```
399
+
400
+ **Phase 2:**
401
+ ```ts
402
+ test("permission request shows buttons", async () => {
403
+ const reply = await sendAndWait(client, BOT, "Create a file called test.txt", 20000)
404
+ // Should eventually get a permission request with buttons
405
+ // (may need to poll multiple messages)
406
+ assertHasButtons(reply)
407
+ })
408
+
409
+ test("clicking Allow continues generation", async () => {
410
+ const permMsg = await sendAndWait(client, BOT, "Run ls -la", 20000)
411
+ await clickInlineButton(client, BOT, permMsg.id, "Allow")
412
+ const result = await waitForBotReply(15000)
413
+ assertContains(result, /.+/)
414
+ })
415
+
416
+ test("/cancel aborts generation", async () => {
417
+ await client.sendMessage(BOT, { message: "Write a very long essay about history" })
418
+ await sleep(2000)
419
+ const reply = await sendAndWait(client, BOT, "/cancel")
420
+ assertContains(reply, /cancel/i)
421
+ })
422
+ ```
423
+
424
+ **Phase 3:**
425
+ ```ts
426
+ test("response streams via message edits", async () => {
427
+ await client.sendMessage(BOT, { message: "Explain what TypeScript is" })
428
+ // Poll message and check it's being edited (text grows)
429
+ await sleep(2000)
430
+ const msg1 = (await client.getMessages(BOT, { limit: 1 }))[0]
431
+ await sleep(3000)
432
+ const msg2 = (await client.getMessages(BOT, { ids: [msg1.id] }))[0]
433
+ // Same message ID, but text may have grown
434
+ expect(msg2.id).toBe(msg1.id)
435
+ })
436
+ ```
437
+
438
+ **Phase 4:**
439
+ ```ts
440
+ test("/list shows sessions with buttons", async () => {
441
+ const reply = await sendAndWait(client, BOT, "/list")
442
+ assertHasButtons(reply)
443
+ })
444
+
445
+ test("/rename changes session title", async () => {
446
+ const reply = await sendAndWait(client, BOT, "/rename My Test Session")
447
+ assertContains(reply, /renamed|My Test Session/i)
448
+ })
449
+
450
+ test("/delete removes session", async () => {
451
+ await sendAndWait(client, BOT, "/new")
452
+ const reply = await sendAndWait(client, BOT, "/delete")
453
+ assertContains(reply, /deleted/i)
454
+ })
455
+ ```
456
+
457
+ **Phase 5:**
458
+ ```ts
459
+ test("/model shows provider keyboard", async () => {
460
+ const reply = await sendAndWait(client, BOT, "/model")
461
+ assertHasButtons(reply)
462
+ })
463
+
464
+ test("/agent shows agent keyboard", async () => {
465
+ const reply = await sendAndWait(client, BOT, "/agent")
466
+ assertHasButtons(reply)
467
+ })
468
+ ```
469
+
470
+ **Phase 6:**
471
+ ```ts
472
+ test("photo attachment is processed", async () => {
473
+ await client.sendFile(BOT, { file: "e2e/fixtures/test-image.png" })
474
+ const reply = await waitForBotReply(20000)
475
+ assertContains(reply, /.+/) // bot acknowledged the image
476
+ })
477
+
478
+ test("document attachment is processed", async () => {
479
+ await client.sendFile(BOT, { file: "e2e/fixtures/test.txt" })
480
+ const reply = await waitForBotReply(20000)
481
+ assertContains(reply, /.+/)
482
+ })
483
+ ```
484
+
485
+ **Phase 7:**
486
+ ```ts
487
+ test("/diff shows file changes", async () => {
488
+ await sendAndWait(client, BOT, "Create a file called hello.py with print('hi')", 30000)
489
+ const reply = await sendAndWait(client, BOT, "/diff")
490
+ assertContains(reply, /hello\.py|diff|change/i)
491
+ })
492
+
493
+ test("/todo shows task list", async () => {
494
+ const reply = await sendAndWait(client, BOT, "/todo")
495
+ // May be empty or have items
496
+ assertContains(reply, /.+/)
497
+ })
498
+
499
+ test("shell mode executes command", async () => {
500
+ const reply = await sendAndWait(client, BOT, "!echo hello from shell", 15000)
501
+ assertContains(reply, /hello from shell/i)
502
+ })
503
+ ```
504
+
505
+ ### 2.7 Phase Gate Rule
506
+
507
+ ```
508
+ Phase N is COMPLETE when:
509
+ 1. All unit tests pass: bun test
510
+ 2. All E2E tests pass: bun test:e2e --filter="phase-[0-N]"
511
+ 3. No regressions: all previous phase E2E tests still green
512
+
513
+ Phase N+1 CANNOT start until Phase N is COMPLETE.
514
+ ```
515
+
516
+ ### 2.8 CI Script
517
+
518
+ ```bash
519
+ # Unit tests (fast, no external deps)
520
+ bun test
521
+
522
+ # E2E tests (requires running bot + test Telegram account)
523
+ TELEGRAM_TEST_ENV=1 bun run e2e/runner.ts setup
524
+ bun test e2e/
525
+ bun run e2e/runner.ts teardown
526
+ ```
527
+
528
+ ---
529
+
530
+ ## 3. OpenCode SDK — What We Use
531
+
532
+ ### 3.1 Session Lifecycle
533
+
534
+ | Method | Signature | Purpose |
535
+ |--------|-----------|---------|
536
+ | `session.create()` | `() → { data: Session }` | Create new session |
537
+ | `session.list()` | `() → { data: Session[] }` | List all sessions |
538
+ | `session.get()` | `({ id }) → { data: Session }` | Get specific session |
539
+ | `session.update()` | `({ id, title })` | Rename session |
540
+ | `session.delete()` | `({ id })` | Delete session |
541
+ | `session.abort()` | `({ sessionID })` | Cancel running response |
542
+ | `session.fork()` | `({ id, messageID })` | Fork from message |
543
+ | `session.share()` | `({ id }) → { data: { share: { url } } }` | Get share URL |
544
+ | `session.unshare()` | `({ id })` | Remove sharing |
545
+ | `session.status()` | `() → { data: SessionStatus }` | Busy/idle per session |
546
+
547
+ **Session type:**
548
+ ```ts
549
+ type Session = {
550
+ id: string
551
+ slug: string
552
+ projectID: string
553
+ directory: string
554
+ parentID?: string
555
+ title: string
556
+ version: string
557
+ time: { created: number; updated: number; archived?: number }
558
+ share?: { url: string }
559
+ summary?: { additions: number; deletions: number; files: number; diffs?: FileDiff[] }
560
+ }
561
+ ```
562
+
563
+ ### 3.2 Messaging
564
+
565
+ | Method | Signature | Purpose |
566
+ |--------|-----------|---------|
567
+ | `session.prompt()` | `({ sessionID, parts, agent, model, variant, messageID })` | Send user message |
568
+ | `session.shell()` | `({ sessionID, command, agent, model })` | Execute shell command |
569
+ | `session.command()` | `({ sessionID, command, arguments, agent, model, variant, parts })` | Execute slash command |
570
+ | `session.messages()` | `({ id, cursor? })` | Get message history (paginated) |
571
+ | `session.message()` | `({ id, messageID })` | Get single message |
572
+ | `session.summarize()` | `({ id })` | Summarize session |
573
+ | `session.revert()` | `({ id, messageID })` | Revert to message |
574
+ | `session.unrevert()` | `({ id })` | Undo revert |
575
+ | `session.diff()` | `({ id })` | Get file diffs |
576
+ | `session.todo()` | `({ id })` | Get task list |
577
+
578
+ **Prompt parts (what we send):**
579
+ ```ts
580
+ type TextPartInput = { id: string; type: "text"; text: string; synthetic?: boolean }
581
+ type FilePartInput = { id: string; type: "file"; mime: string; url: string; filename?: string }
582
+ type AgentPartInput = { id: string; type: "agent"; name: string }
583
+ ```
584
+
585
+ **For images/files from Telegram, we'd download them and send as `file://` URLs or data URLs.**
586
+
587
+ ### 3.3 SSE Events
588
+
589
+ **Connection:** `sdk.event.subscribe()` returns an async iterable of `Event` objects.
590
+ For global events: we listen to `global.event()` which wraps each event in `{ directory, payload }`.
591
+
592
+ **Key event types for the Telegram bot:**
593
+
594
+ | Event Type | Properties | Action |
595
+ |------------|------------|--------|
596
+ | `message.updated` | `{ info: Message }` | New/updated message (both user and assistant) |
597
+ | `message.part.updated` | `{ part: Part, delta?: string }` | Text streaming, tool calls, reasoning |
598
+ | `message.removed` | `{ sessionID, messageID }` | Message deleted |
599
+ | `session.status` | `{ sessionID, status: SessionStatus }` | Busy/idle/retry |
600
+ | `session.idle` | `{ sessionID }` | Turn complete |
601
+ | `session.error` | `{ sessionID?, error? }` | Error occurred |
602
+ | `session.created` | `{ info: Session }` | New session |
603
+ | `session.updated` | `{ info: Session }` | Session metadata changed |
604
+ | `session.deleted` | `{ info: Session }` | Session deleted |
605
+ | `session.diff` | `{ sessionID, diff: FileDiff[] }` | File diffs updated |
606
+ | `todo.updated` | `{ sessionID, todos: Todo[] }` | Todo list changed |
607
+ | `permission.asked` | PermissionRequest | Permission needed |
608
+ | `permission.replied` | `{ sessionID, requestID, reply }` | Permission answered |
609
+ | `question.asked` | QuestionRequest | Question to user |
610
+ | `question.replied` | `{ sessionID, requestID, answers }` | Question answered |
611
+ | `question.rejected` | `{ sessionID, requestID }` | Question rejected |
612
+
613
+ **Part types (what the assistant sends back):**
614
+ ```ts
615
+ type Part =
616
+ | TextPart // { type: "text", text: string }
617
+ | ToolPart // { type: "tool", tool: string, state: ToolState }
618
+ | ReasoningPart // { type: "reasoning", text: string }
619
+ | FilePart // { type: "file", mime, url, filename }
620
+ | SubtaskPart // { type: "subtask", description, agent }
621
+ | StepStartPart // { type: "step-start" }
622
+ | StepFinishPart // { type: "step-finish", cost, tokens }
623
+ | SnapshotPart // { type: "snapshot" }
624
+ | PatchPart // { type: "patch", hash, files }
625
+ | CompactionPart // { type: "compaction", auto }
626
+ | RetryPart // { type: "retry", attempt, error }
627
+
628
+ // Tool states:
629
+ type ToolState =
630
+ | { status: "pending", input }
631
+ | { status: "running", input, title?, time: { start } }
632
+ | { status: "completed", input, output, title, time: { start, end } }
633
+ | { status: "error", input, error, time: { start, end } }
634
+ ```
635
+
636
+ ### 3.4 Permissions
637
+
638
+ ```ts
639
+ type PermissionRequest = {
640
+ id: string
641
+ sessionID: string
642
+ permission: string // e.g. "bash", "edit", "write"
643
+ patterns: string[] // e.g. ["rm -rf *"]
644
+ metadata: Record<string, unknown>
645
+ always: string[] // available "always" patterns
646
+ tool?: { messageID: string; callID: string }
647
+ }
648
+
649
+ // Respond:
650
+ sdk.permission.respond({
651
+ id: permissionRequest.id,
652
+ sessionID: permissionRequest.sessionID,
653
+ reply: "once" | "always" | "reject"
654
+ })
655
+ ```
656
+
657
+ ### 3.5 Questions
658
+
659
+ ```ts
660
+ type QuestionRequest = {
661
+ id: string
662
+ sessionID: string
663
+ questions: Array<{
664
+ question: string
665
+ header: string // short label (max 30 chars)
666
+ options: Array<{ label: string; description: string }>
667
+ multiple?: boolean
668
+ custom?: boolean
669
+ }>
670
+ }
671
+
672
+ // Reply:
673
+ sdk.question.reply({
674
+ id: questionRequest.id,
675
+ sessionID: questionRequest.sessionID,
676
+ answers: [["selected_option"]] // Array<Array<string>>
677
+ })
678
+
679
+ // Reject:
680
+ sdk.question.reject({
681
+ id: questionRequest.id,
682
+ sessionID: questionRequest.sessionID,
683
+ })
684
+ ```
685
+
686
+ ### 3.6 Other Useful Endpoints
687
+
688
+ | Method | Purpose |
689
+ |--------|---------|
690
+ | `app.agents()` | List available agents (name, description, color) |
691
+ | `provider.list()` | List providers with their models |
692
+ | `provider.auth()` | Check provider auth status |
693
+ | `config.get()` | Get project config |
694
+ | `command.list()` | List available slash commands |
695
+ | `global.health()` | Health check |
696
+ | `vcs.get()` | Git branch info |
697
+
698
+ ---
699
+
700
+ ## 4. OpenClaw Telegram — Reference Patterns
701
+
702
+ ### 4.1 Message Flow
703
+
704
+ OpenClaw's message flow (what we adapt to SDK):
705
+
706
+ ```
707
+ Telegram Update
708
+ → bot.on("message") [bot-handlers.ts:668]
709
+ → Access control (allowlists, group policy) [bot-handlers.ts:695-773]
710
+ → Text fragment assembly (>4000 chars split) [bot-handlers.ts:776-836]
711
+ → Media group buffering [bot-handlers.ts:839-870]
712
+ → Media resolution (download) [bot/delivery.ts:294]
713
+ → Inbound debouncing [bot-handlers.ts:916]
714
+ → processMessage() [bot-message.ts:27]
715
+ → buildTelegramMessageContext() [bot-message-context.ts:127]
716
+ → Route resolution (session key)
717
+ → DM/Group access control
718
+ → Mention detection
719
+ → ACK reaction
720
+ → Format inbound envelope
721
+ → dispatchTelegramMessage() [bot-message-dispatch.ts:60]
722
+ → Draft stream setup [bot-message-dispatch.ts:99-109]
723
+ → Typing indicator
724
+ → dispatchReplyWithBufferedBlockDispatcher()
725
+ → AI agent processes message
726
+ → Partial replies → update draft stream
727
+ → Final reply → deliverReplies()
728
+ → Draft stream stop
729
+ → ACK reaction cleanup
730
+ ```
731
+
732
+ **Our equivalent (SDK-based):**
733
+
734
+ ```
735
+ Telegram Update
736
+ → bot.on("message")
737
+ → Extract chatId, text, media (minimal from ctx)
738
+ → SessionManager.getOrCreate(chatId) → sessionId
739
+ → sdk.session.prompt({ sessionID, parts: [...] })
740
+ → TurnManager.start(sessionId, chatId)
741
+
742
+ [Meanwhile, EventBus receives SSE events]
743
+ → message.part.updated (type: "text")
744
+ → DraftStream.update(text) [edit message in real-time]
745
+ → message.part.updated (type: "tool")
746
+ → Show tool progress in chat
747
+ → permission.asked
748
+ → Send inline keyboard (Allow/Deny/Always)
749
+ → question.asked
750
+ → Send inline keyboard with options
751
+ → session.idle
752
+ → DraftStream.stop()
753
+ → Send final formatted response
754
+ → TurnManager.end(sessionId)
755
+ ```
756
+
757
+ ### 4.2 Draft Streaming Pattern (from OpenClaw)
758
+
759
+ OpenClaw uses `sendMessageDraft` (Telegram Business API) for live streaming.
760
+ For our MVP, we'll use `editMessageText` instead (more broadly available):
761
+
762
+ ```ts
763
+ // OpenClaw pattern [draft-stream.ts:13-139]:
764
+ // - Throttled at 300ms intervals
765
+ // - Max 4096 chars per draft
766
+ // - Graceful fallback on failure
767
+ // - pendingText / inFlight / timer pattern prevents race conditions
768
+
769
+ // Our adaptation:
770
+ function createDraftEditor(params: {
771
+ bot: Bot
772
+ chatId: number
773
+ messageId: number // the "placeholder" message we edit
774
+ maxChars: number // 4096
775
+ throttleMs: number // 300-500ms
776
+ }): { update(text: string): void; stop(): void }
777
+ ```
778
+
779
+ ### 4.3 Inline Keyboard Patterns (from OpenClaw)
780
+
781
+ **Model selection** [model-buttons.ts]:
782
+ ```
783
+ Callback data patterns (max 64 bytes):
784
+ - mdl_prov → show providers list
785
+ - mdl_list_{prov}_{pg} → show models for provider (page N)
786
+ - mdl_sel_{provider/id} → select model
787
+ - mdl_back → back to providers
788
+
789
+ Keyboard layout:
790
+ - Provider buttons: 2 per row, showing "provider (count)"
791
+ - Model buttons: 1 per row, current model marked with ✓
792
+ - Pagination: ◀ Prev | 1/3 | Next ▶
793
+ - Back button at bottom
794
+ ```
795
+
796
+ **Permission buttons** (our design):
797
+ ```
798
+ Callback data patterns:
799
+ - perm:once:{requestId}
800
+ - perm:always:{requestId}
801
+ - perm:deny:{requestId}
802
+
803
+ Keyboard: [ ✓ Allow Once ] [ ✓ Always ] [ ✗ Deny ]
804
+ ```
805
+
806
+ **Question buttons** (our design):
807
+ ```
808
+ Callback data patterns:
809
+ - q:{requestId}:{optionIndex}
810
+ - q:{requestId}:reject
811
+
812
+ Keyboard: options as buttons, reject as last row
813
+ ```
814
+
815
+ ### 4.4 Message Formatting (from OpenClaw)
816
+
817
+ OpenClaw converts markdown → Telegram HTML [format.ts]:
818
+ ```
819
+ **bold** → <b>bold</b>
820
+ *italic* → <i>italic</i>
821
+ ~~strike~~ → <s>strike</s>
822
+ `code` → <code>code</code>
823
+ ```code``` → <pre><code>code</code></pre>
824
+ [text](url) → <a href="url">text</a>
825
+ & < > → &amp; &lt; &gt; (HTML entities)
826
+ ```
827
+
828
+ Message chunking: split at 4096 chars preserving tag boundaries.
829
+ HTML parse errors: fall back to plain text (PARSE_ERR_RE pattern).
830
+
831
+ ### 4.5 Media Handling (from OpenClaw)
832
+
833
+ OpenClaw resolves media from Telegram [bot/delivery.ts:294-436]:
834
+ ```
835
+ Photo → file = msg.photo[last] (highest resolution)
836
+ Video → file = msg.video
837
+ Audio → file = msg.audio or msg.voice
838
+ Doc → file = msg.document
839
+ Sticker → file = msg.sticker (only static WEBP)
840
+
841
+ Download flow:
842
+ 1. ctx.getFile() → { file_path }
843
+ 2. fetch(https://api.telegram.org/file/bot{token}/{file_path})
844
+ 3. Detect MIME type
845
+ 4. Save to temp file
846
+ 5. Return { path, contentType }
847
+ ```
848
+
849
+ **Our adaptation**: Download → base64 data URL → send as FilePartInput in prompt.
850
+
851
+ ---
852
+
853
+ ## 5. Phase 0 — E2E Infrastructure + Bot Skeleton
854
+
855
+ **Goal:** Bot connects to Telegram test env, responds to `/start`. E2E runner can
856
+ send messages and verify responses automatically. This is the foundation for all
857
+ subsequent phases.
858
+
859
+ ### 5.1 Files
860
+
861
+ ```
862
+ opencode-telegram/
863
+ src/
864
+ index.ts # Entry point (bot startup + graceful shutdown)
865
+ bot.ts # Grammy bot setup, /start handler
866
+ config.ts # Env vars: bot token, SDK URL, test env flag
867
+ types.ts # Shared types
868
+ e2e/
869
+ client.ts # gramjs userbot wrapper (createTestClient)
870
+ helpers.ts # sendAndWait, clickInlineButton, assertContains
871
+ runner.ts # setup/teardown (spawn bot, connect client)
872
+ phase-0.test.ts # /start → verify response
873
+ package.json
874
+ tsconfig.json
875
+ bunfig.toml # bun test config
876
+ ```
877
+
878
+ ### 5.2 Config (`config.ts`)
879
+
880
+ ```ts
881
+ export const config = {
882
+ botToken: process.env.TELEGRAM_BOT_TOKEN!,
883
+ opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4096",
884
+ projectDirectory: process.env.OPENCODE_DIRECTORY ?? process.cwd(),
885
+ testEnv: process.env.TELEGRAM_TEST_ENV === "1",
886
+
887
+ // E2E test config (only used by test runner)
888
+ e2e: {
889
+ apiId: Number(process.env.TELEGRAM_API_ID ?? 0),
890
+ apiHash: process.env.TELEGRAM_API_HASH ?? "",
891
+ session: process.env.TELEGRAM_SESSION ?? "",
892
+ botUsername: process.env.TELEGRAM_BOT_USERNAME ?? "",
893
+ },
894
+ }
895
+ ```
896
+
897
+ ### 5.3 Bot Setup (`bot.ts`)
898
+
899
+ ```ts
900
+ import { Bot } from "grammy"
901
+ import { config } from "./config"
902
+
903
+ export function createBot() {
904
+ const bot = new Bot(config.botToken, {
905
+ client: {
906
+ // Use test API if configured
907
+ ...(config.testEnv && {
908
+ apiRoot: `https://api.telegram.org/bot${config.botToken}/test`,
909
+ }),
910
+ },
911
+ })
912
+
913
+ // /start command
914
+ bot.command("start", async (ctx) => {
915
+ await ctx.reply(
916
+ "OpenCode Telegram Bot\n\n" +
917
+ "Send any message to start coding.\n" +
918
+ "/new — New session\n" +
919
+ "/cancel — Stop generation"
920
+ )
921
+ })
922
+
923
+ return bot
924
+ }
925
+ ```
926
+
927
+ ### 5.4 Entry Point (`index.ts`)
928
+
929
+ ```ts
930
+ import { createBot } from "./bot"
931
+
932
+ const bot = createBot()
933
+
934
+ // Graceful shutdown
935
+ const shutdown = async () => {
936
+ await bot.stop()
937
+ process.exit(0)
938
+ }
939
+ process.on("SIGINT", shutdown)
940
+ process.on("SIGTERM", shutdown)
941
+
942
+ // Start polling
943
+ await bot.start({
944
+ onStart: () => console.log("Bot started"),
945
+ })
946
+ ```
947
+
948
+ ### 5.5 E2E Test
949
+
950
+ ```ts
951
+ // e2e/phase-0.test.ts
952
+ import { describe, test, beforeAll, afterAll } from "bun:test"
953
+ import { setup, teardown, getClient, getBotUsername } from "./runner"
954
+ import { sendAndWait, assertContains } from "./helpers"
955
+
956
+ describe("Phase 0 — Bot Skeleton", () => {
957
+ beforeAll(async () => await setup())
958
+ afterAll(async () => await teardown())
959
+
960
+ test("bot responds to /start", async () => {
961
+ const client = getClient()
962
+ const reply = await sendAndWait(client, getBotUsername(), "/start")
963
+ assertContains(reply, "OpenCode Telegram Bot")
964
+ })
965
+
966
+ test("bot ignores unknown commands gracefully", async () => {
967
+ const client = getClient()
968
+ // Should not crash — just no response or generic response
969
+ await client.sendMessage(getBotUsername(), { message: "/nonexistent" })
970
+ // No crash = pass
971
+ })
972
+ })
973
+ ```
974
+
975
+ ### 5.6 Phase 0 TDD Order
976
+
977
+ ```
978
+ RED+GREEN: config.test.ts → config.ts (env parsing, defaults)
979
+ RED+GREEN: bot.test.ts → bot.ts (/start handler returns expected text)
980
+ ─────────────────────────────
981
+ bun test → unit green
982
+ E2E: phase-0.test.ts → real validation
983
+ ```
984
+
985
+ ### 5.7 Phase 0 Acceptance Criteria
986
+
987
+ - [ ] `bun run src/index.ts` starts bot without errors
988
+ - [ ] Bot responds to `/start` in Telegram (test env)
989
+ - [ ] E2E runner connects as userbot, sends `/start`, gets reply
990
+ - [ ] `bun test` passes (unit)
991
+ - [ ] `bun test e2e/phase-0.test.ts` passes (E2E)
992
+ - [ ] Graceful shutdown on SIGINT
993
+
994
+ ---
995
+
996
+ ## 6. Phase 1 — Core Loop (MVP)
997
+
998
+ **Goal:** Send a message, get a response. The absolute minimum working bot.
999
+
1000
+ ### 6.1 Files
1001
+
1002
+ ```
1003
+ opencode-telegram/
1004
+ src/
1005
+ index.ts # Entry point
1006
+ bot.ts # Grammy bot setup
1007
+ sdk.ts # SDK client factory
1008
+ session-manager.ts # LRU Map<chatKey, SessionEntry>
1009
+ event-bus.ts # Single SSE connection + dispatcher
1010
+ turn-manager.ts # Per-turn lifecycle (AbortController)
1011
+ send/
1012
+ format.ts # Markdown → Telegram HTML
1013
+ chunker.ts # Split at 4096 chars
1014
+ config.ts # Bot token, SDK URL, etc.
1015
+ types.ts # Shared types
1016
+ ```
1017
+
1018
+ ### 6.2 Entry Point (`index.ts`)
1019
+
1020
+ ```ts
1021
+ // 1. Load config (bot token, opencode URL, project directory)
1022
+ // 2. Create SDK client
1023
+ // 3. Create Grammy bot with sequentialize + throttle
1024
+ // 4. Create SessionManager (LRU, max 500, TTL 30min)
1025
+ // 5. Create EventBus (single SSE to sdk.event.subscribe())
1026
+ // 6. Create TurnManager
1027
+ // 7. Register handlers
1028
+ // 8. Start polling (or webhook)
1029
+ // 9. Graceful shutdown on SIGINT/SIGTERM
1030
+ ```
1031
+
1032
+ ### 6.3 Session Manager (`session-manager.ts`)
1033
+
1034
+ ```ts
1035
+ type SessionEntry = {
1036
+ sessionId: string
1037
+ directory: string
1038
+ createdAt: number
1039
+ lastAccessAt: number
1040
+ }
1041
+
1042
+ class SessionManager {
1043
+ private map: Map<string, SessionEntry> // chatKey → entry
1044
+ private reverseMap: Map<string, string> // sessionId → chatKey
1045
+ private readonly maxEntries: number
1046
+ private readonly ttlMs: number
1047
+
1048
+ // chatKey = `${chatId}` for DMs, `${chatId}:topic:${threadId}` for forums
1049
+
1050
+ async getOrCreate(chatKey: string, sdk: OpencodeClient): Promise<SessionEntry>
1051
+ get(chatKey: string): SessionEntry | undefined
1052
+ getBySessionId(sessionId: string): { chatKey: string; entry: SessionEntry } | undefined
1053
+ remove(chatKey: string): void
1054
+ private evict(): void // remove oldest entries beyond maxEntries
1055
+ private cleanup(): void // remove expired entries (called periodically)
1056
+ }
1057
+ ```
1058
+
1059
+ ### 6.4 Event Bus (`event-bus.ts`)
1060
+
1061
+ ```ts
1062
+ class EventBus {
1063
+ private abortController: AbortController
1064
+ private reconnectAttempts: number
1065
+ private readonly sdk: OpencodeClient
1066
+ private readonly sessionManager: SessionManager
1067
+ private readonly onEvent: (sessionId: string, chatKey: string, event: Event) => void
1068
+
1069
+ constructor(params: {
1070
+ sdk: OpencodeClient
1071
+ sessionManager: SessionManager
1072
+ onEvent: (sessionId: string, chatKey: string, event: Event) => void
1073
+ })
1074
+
1075
+ async start(): Promise<void> // connect SSE, start listening
1076
+ stop(): void // abort, clean up
1077
+
1078
+ private async connect(): Promise<void> {
1079
+ // 1. Subscribe to SDK events
1080
+ // 2. For each event:
1081
+ // a. Extract sessionId from event properties
1082
+ // b. Look up chatKey via sessionManager.getBySessionId(sessionId)
1083
+ // c. If found, call onEvent(sessionId, chatKey, event)
1084
+ // 3. On disconnect: reconnect with exponential backoff
1085
+ }
1086
+ }
1087
+ ```
1088
+
1089
+ **Critical**: Only ONE SSE connection. Events are routed via the sessionManager's reverse map.
1090
+
1091
+ ### 6.5 Turn Manager (`turn-manager.ts`)
1092
+
1093
+ ```ts
1094
+ type ActiveTurn = {
1095
+ sessionId: string
1096
+ chatId: number
1097
+ abortController: AbortController
1098
+ draftMessageId?: number
1099
+ timers: Set<ReturnType<typeof setTimeout>>
1100
+ }
1101
+
1102
+ class TurnManager {
1103
+ private active: Map<string, ActiveTurn> // sessionId → turn
1104
+
1105
+ start(sessionId: string, chatId: number): ActiveTurn
1106
+ get(sessionId: string): ActiveTurn | undefined
1107
+ end(sessionId: string): void // abort controller, clear timers, remove entry
1108
+ abort(sessionId: string): void // same as end() but also calls sdk.session.abort()
1109
+
1110
+ addTimer(sessionId: string, timer: ReturnType<typeof setTimeout>): void
1111
+ clearTimer(sessionId: string, timer: ReturnType<typeof setTimeout>): void
1112
+ }
1113
+ ```
1114
+
1115
+ ### 6.6 Message Handler
1116
+
1117
+ ```ts
1118
+ bot.on("message", async (ctx) => {
1119
+ const msg = ctx.message
1120
+ if (!msg) return
1121
+
1122
+ const chatId = msg.chat.id
1123
+ const text = msg.text?.trim()
1124
+ if (!text) return
1125
+
1126
+ // Extract minimal data from ctx (never store ctx itself)
1127
+ const chatKey = String(chatId)
1128
+
1129
+ // Get or create session
1130
+ const entry = await sessionManager.getOrCreate(chatKey, sdk)
1131
+
1132
+ // Send typing indicator
1133
+ await bot.api.sendChatAction(chatId, "typing")
1134
+
1135
+ // Start turn
1136
+ turnManager.start(entry.sessionId, chatId)
1137
+
1138
+ // Send prompt to OpenCode
1139
+ await sdk.session.prompt({
1140
+ sessionID: entry.sessionId,
1141
+ parts: [{ id: generateId(), type: "text", text }],
1142
+ }).catch((err) => {
1143
+ bot.api.sendMessage(chatId, `Error: ${err.message}`)
1144
+ turnManager.end(entry.sessionId)
1145
+ })
1146
+ })
1147
+ ```
1148
+
1149
+ ### 6.7 Event Handling (in entry point)
1150
+
1151
+ ```ts
1152
+ const eventBus = new EventBus({
1153
+ sdk,
1154
+ sessionManager,
1155
+ onEvent: (sessionId, chatKey, event) => {
1156
+ const chatId = Number(chatKey.split(":")[0])
1157
+
1158
+ switch (event.type) {
1159
+ case "message.part.updated": {
1160
+ const part = event.properties.part
1161
+ if (part.type === "text") {
1162
+ // Accumulate text, update draft (Phase 3) or store for final send
1163
+ turnState.accumulateText(sessionId, part.text)
1164
+ }
1165
+ if (part.type === "tool") {
1166
+ // Show tool progress (Phase 3)
1167
+ }
1168
+ break
1169
+ }
1170
+ case "session.idle": {
1171
+ // Turn complete — send final response
1172
+ const text = turnState.getFinalText(sessionId)
1173
+ if (text) {
1174
+ sendFormattedResponse(chatId, text)
1175
+ }
1176
+ turnManager.end(sessionId)
1177
+ break
1178
+ }
1179
+ case "session.error": {
1180
+ const error = event.properties.error
1181
+ bot.api.sendMessage(chatId, `Error: ${error?.data?.message ?? "Unknown error"}`)
1182
+ turnManager.end(sessionId)
1183
+ break
1184
+ }
1185
+ case "permission.asked": {
1186
+ // Phase 2: send inline keyboard
1187
+ break
1188
+ }
1189
+ case "question.asked": {
1190
+ // Phase 2: send inline keyboard
1191
+ break
1192
+ }
1193
+ }
1194
+ },
1195
+ })
1196
+ ```
1197
+
1198
+ ### 6.8 Response Formatting
1199
+
1200
+ ```ts
1201
+ // format.ts — simplified from OpenClaw's approach
1202
+ function markdownToTelegramHtml(markdown: string): string {
1203
+ // Bold: **text** → <b>text</b>
1204
+ // Italic: *text* → <i>text</i>
1205
+ // Code: `text` → <code>text</code>
1206
+ // Code block: ```text``` → <pre><code>text</code></pre>
1207
+ // Links: [text](url) → <a href="url">text</a>
1208
+ // Escape: & < > → &amp; &lt; &gt;
1209
+ }
1210
+
1211
+ // chunker.ts
1212
+ function chunkMessage(html: string, limit = 4096): string[] {
1213
+ // Split at tag boundaries, never break inside tags
1214
+ // Each chunk ≤ limit chars
1215
+ }
1216
+
1217
+ // Send with fallback
1218
+ async function sendFormattedResponse(chatId: number, markdown: string) {
1219
+ const html = markdownToTelegramHtml(markdown)
1220
+ const chunks = chunkMessage(html)
1221
+ for (const chunk of chunks) {
1222
+ try {
1223
+ await bot.api.sendMessage(chatId, chunk, { parse_mode: "HTML" })
1224
+ } catch (err) {
1225
+ // HTML parse error → retry as plain text
1226
+ if (/can't parse entities/i.test(String(err))) {
1227
+ await bot.api.sendMessage(chatId, markdown.slice(0, 4096))
1228
+ }
1229
+ }
1230
+ }
1231
+ }
1232
+ ```
1233
+
1234
+ ### 6.9 `/new` Command
1235
+
1236
+ ```ts
1237
+ bot.command("new", async (ctx) => {
1238
+ const chatId = ctx.message.chat.id
1239
+ const chatKey = String(chatId)
1240
+
1241
+ // Remove existing session mapping (don't delete from OpenCode)
1242
+ sessionManager.remove(chatKey)
1243
+
1244
+ // Create fresh session
1245
+ const entry = await sessionManager.getOrCreate(chatKey, sdk)
1246
+
1247
+ await bot.api.sendMessage(chatId, `New session started.`)
1248
+ })
1249
+ ```
1250
+
1251
+ ### 6.10 `/start` Command
1252
+
1253
+ ```ts
1254
+ bot.command("start", async (ctx) => {
1255
+ await ctx.reply(
1256
+ "OpenCode Telegram Bot\n\n" +
1257
+ "Send any message to start coding.\n" +
1258
+ "/new — Start a new session\n" +
1259
+ "/cancel — Stop current generation"
1260
+ )
1261
+ })
1262
+ ```
1263
+
1264
+ ### 6.11 Phase 1 TDD Order
1265
+
1266
+ ```
1267
+ RED+GREEN: send/format.test.ts → send/format.ts
1268
+ RED+GREEN: send/chunker.test.ts → send/chunker.ts
1269
+ RED+GREEN: session-manager.test.ts → session-manager.ts
1270
+ RED+GREEN: turn-manager.test.ts → turn-manager.ts
1271
+ RED+GREEN: event-bus.test.ts → event-bus.ts
1272
+ RED+GREEN: bot.test.ts → bot.ts (message handler)
1273
+ ─────────────────────────────────
1274
+ bun test → all unit green
1275
+ E2E: phase-1.test.ts → real validation
1276
+ ```
1277
+
1278
+ ### 6.12 Phase 1 Acceptance Criteria
1279
+
1280
+ - [ ] Text message → `session.prompt()` → SSE response → formatted reply in chat
1281
+ - [ ] `/new` creates fresh session
1282
+ - [ ] Long response (>4096 chars) is chunked correctly
1283
+ - [ ] Markdown is converted to Telegram HTML
1284
+ - [ ] HTML parse errors fall back to plain text
1285
+ - [ ] Errors are shown in chat
1286
+ - [ ] `bun test` passes (all unit tests)
1287
+ - [ ] `bun test e2e/phase-1.test.ts` passes (E2E)
1288
+ - [ ] All Phase 0 E2E tests still pass (regression)
1289
+
1290
+ ---
1291
+
1292
+ ## 7. Phase 2 — Interactive Controls
1293
+
1294
+ **Goal:** Handle permissions, questions, and abort — without these the AI agent gets stuck.
1295
+
1296
+ ### 7.1 Permission Handling
1297
+
1298
+ ```ts
1299
+ // On permission.asked event:
1300
+ case "permission.asked": {
1301
+ const perm = event.properties as PermissionRequest
1302
+ const description = `${perm.permission}: ${perm.patterns.join(", ")}`
1303
+
1304
+ await bot.api.sendMessage(chatId, `Permission needed:\n<code>${escapeHtml(description)}</code>`, {
1305
+ parse_mode: "HTML",
1306
+ reply_markup: {
1307
+ inline_keyboard: [[
1308
+ { text: "✓ Allow", callback_data: `perm:once:${perm.id}` },
1309
+ { text: "✓ Always", callback_data: `perm:always:${perm.id}` },
1310
+ { text: "✗ Deny", callback_data: `perm:deny:${perm.id}` },
1311
+ ]]
1312
+ }
1313
+ })
1314
+ break
1315
+ }
1316
+
1317
+ // Callback handler:
1318
+ bot.on("callback_query", async (ctx) => {
1319
+ const data = ctx.callbackQuery.data
1320
+ if (!data) return
1321
+ await ctx.answerCallbackQuery()
1322
+
1323
+ if (data.startsWith("perm:")) {
1324
+ const [, action, requestId] = data.split(":")
1325
+ // Find sessionId from the permission request
1326
+ const reply = action === "once" ? "once" : action === "always" ? "always" : "reject"
1327
+ await sdk.permission.respond({
1328
+ id: requestId,
1329
+ sessionID: /* lookup from turn or permission cache */,
1330
+ reply,
1331
+ })
1332
+ // Edit the message to show the decision
1333
+ await ctx.editMessageText(`Permission ${reply === "reject" ? "denied" : "granted"}: ${reply}`)
1334
+ }
1335
+ })
1336
+ ```
1337
+
1338
+ **Key design: callback data encodes everything needed.** No in-memory storage for pending permissions.
1339
+
1340
+ To resolve sessionId from requestId, we keep a bounded Map<requestId, sessionId> with TTL
1341
+ that gets populated when `permission.asked` events arrive and cleaned when replied.
1342
+
1343
+ ### 7.2 Question Handling
1344
+
1345
+ ```ts
1346
+ // On question.asked event:
1347
+ case "question.asked": {
1348
+ const req = event.properties as QuestionRequest
1349
+ for (const q of req.questions) {
1350
+ const rows = q.options.map((opt, i) => [{
1351
+ text: opt.label,
1352
+ callback_data: `q:${req.id}:${i}`, // encode in string
1353
+ }])
1354
+ rows.push([{ text: "✗ Skip", callback_data: `q:${req.id}:reject` }])
1355
+
1356
+ await bot.api.sendMessage(chatId, q.question, {
1357
+ reply_markup: { inline_keyboard: rows }
1358
+ })
1359
+ }
1360
+ break
1361
+ }
1362
+
1363
+ // Callback:
1364
+ if (data.startsWith("q:")) {
1365
+ const [, requestId, value] = data.split(":")
1366
+ if (value === "reject") {
1367
+ await sdk.question.reject({ id: requestId, sessionID: /* lookup */ })
1368
+ } else {
1369
+ const optionIndex = parseInt(value)
1370
+ const option = /* lookup from cached question */
1371
+ await sdk.question.reply({
1372
+ id: requestId,
1373
+ sessionID: /* lookup */,
1374
+ answers: [[option.label]],
1375
+ })
1376
+ }
1377
+ await ctx.editMessageText(`Answered: ${value === "reject" ? "skipped" : "selected"}`)
1378
+ }
1379
+ ```
1380
+
1381
+ ### 7.3 Abort
1382
+
1383
+ ```ts
1384
+ bot.command("cancel", async (ctx) => {
1385
+ const chatId = ctx.message.chat.id
1386
+ const chatKey = String(chatId)
1387
+ const entry = sessionManager.get(chatKey)
1388
+ if (!entry) return ctx.reply("No active session.")
1389
+
1390
+ const turn = turnManager.get(entry.sessionId)
1391
+ if (!turn) return ctx.reply("Nothing running.")
1392
+
1393
+ await sdk.session.abort({ sessionID: entry.sessionId })
1394
+ turnManager.end(entry.sessionId)
1395
+ await ctx.reply("Generation cancelled.")
1396
+ })
1397
+ ```
1398
+
1399
+ ### 7.4 Typing Indicator
1400
+
1401
+ ```ts
1402
+ // Maintain typing indicator while turn is active
1403
+ function startTypingLoop(chatId: number, sessionId: string, signal: AbortSignal) {
1404
+ const send = () => {
1405
+ if (signal.aborted) return
1406
+ bot.api.sendChatAction(chatId, "typing").catch(() => {})
1407
+ }
1408
+ send()
1409
+ const interval = setInterval(send, 4000) // Telegram typing expires after ~5s
1410
+ signal.addEventListener("abort", () => clearInterval(interval))
1411
+ }
1412
+
1413
+ // In turn start:
1414
+ const turn = turnManager.start(sessionId, chatId)
1415
+ startTypingLoop(chatId, sessionId, turn.abortController.signal)
1416
+ ```
1417
+
1418
+ ### 7.5 Phase 2 TDD Order
1419
+
1420
+ ```
1421
+ RED+GREEN: handlers/permissions.test.ts → handlers/permissions.ts
1422
+ RED+GREEN: handlers/questions.test.ts → handlers/questions.ts
1423
+ RED+GREEN: handlers/callback.test.ts → handlers/callback.ts
1424
+ ─────────────────────────────────
1425
+ bun test → all unit green
1426
+ E2E: phase-2.test.ts → real validation
1427
+ ```
1428
+
1429
+ ### 7.6 Phase 2 Acceptance Criteria
1430
+
1431
+ - [ ] Permission request shows inline buttons (Allow/Always/Deny)
1432
+ - [ ] Clicking Allow continues AI generation
1433
+ - [ ] Clicking Deny stops the tool call
1434
+ - [ ] Question shows options as inline buttons
1435
+ - [ ] Clicking option sends reply to SDK
1436
+ - [ ] `/cancel` aborts running generation
1437
+ - [ ] Typing indicator active during turn
1438
+ - [ ] `bun test` passes
1439
+ - [ ] `bun test e2e/phase-2.test.ts` passes
1440
+ - [ ] All Phase 0-1 E2E tests still pass
1441
+
1442
+ ---
1443
+
1444
+ ## 8. Phase 3 — Response UX
1445
+
1446
+ **Goal:** Make responses feel responsive and informative.
1447
+
1448
+ ### 8.1 Draft Streaming
1449
+
1450
+ ```ts
1451
+ // When first text part arrives, send a placeholder message.
1452
+ // Then edit it as more text streams in (throttled at 300-500ms).
1453
+
1454
+ class DraftStream {
1455
+ private messageId: number | null = null
1456
+ private lastText = ""
1457
+ private lastSentAt = 0
1458
+ private pending = ""
1459
+ private timer: ReturnType<typeof setTimeout> | null = null
1460
+ private stopped = false
1461
+ private readonly throttleMs = 400
1462
+
1463
+ constructor(
1464
+ private readonly bot: Bot,
1465
+ private readonly chatId: number,
1466
+ private readonly signal: AbortSignal,
1467
+ ) {
1468
+ signal.addEventListener("abort", () => this.stop())
1469
+ }
1470
+
1471
+ async update(text: string) {
1472
+ if (this.stopped || !text.trim()) return
1473
+ this.pending = text
1474
+
1475
+ if (!this.messageId) {
1476
+ // Send initial message
1477
+ const truncated = text.slice(0, 4096)
1478
+ const html = markdownToTelegramHtml(truncated)
1479
+ const msg = await this.bot.api.sendMessage(this.chatId, html, { parse_mode: "HTML" })
1480
+ .catch(() => null)
1481
+ if (msg) this.messageId = msg.message_id
1482
+ this.lastText = truncated
1483
+ this.lastSentAt = Date.now()
1484
+ return
1485
+ }
1486
+
1487
+ this.scheduleFlush()
1488
+ }
1489
+
1490
+ private scheduleFlush() {
1491
+ if (this.timer) return
1492
+ const delay = Math.max(0, this.throttleMs - (Date.now() - this.lastSentAt))
1493
+ this.timer = setTimeout(() => this.flush(), delay)
1494
+ }
1495
+
1496
+ private async flush() {
1497
+ this.timer = null
1498
+ const text = this.pending.slice(0, 4096)
1499
+ if (text === this.lastText || !this.messageId) return
1500
+
1501
+ const html = markdownToTelegramHtml(text)
1502
+ await this.bot.api.editMessageText(this.chatId, this.messageId, html, { parse_mode: "HTML" })
1503
+ .catch((err) => {
1504
+ if (/message is not modified/i.test(String(err))) return
1505
+ if (/can't parse entities/i.test(String(err))) {
1506
+ return this.bot.api.editMessageText(this.chatId, this.messageId!, text.slice(0, 4096))
1507
+ }
1508
+ this.stopped = true
1509
+ })
1510
+ this.lastText = text
1511
+ this.lastSentAt = Date.now()
1512
+ }
1513
+
1514
+ stop() {
1515
+ this.stopped = true
1516
+ if (this.timer) { clearTimeout(this.timer); this.timer = null }
1517
+ }
1518
+
1519
+ getMessageId() { return this.messageId }
1520
+ }
1521
+ ```
1522
+
1523
+ ### 8.2 Tool Call Progress
1524
+
1525
+ ```ts
1526
+ case "message.part.updated": {
1527
+ const part = event.properties.part
1528
+ if (part.type === "tool" && part.state.status === "completed") {
1529
+ const msg = `⚙ ${part.tool}: ${part.state.title}`
1530
+ // Append to draft or send as separate message
1531
+ }
1532
+ if (part.type === "tool" && part.state.status === "running" && part.state.title) {
1533
+ // Update typing-like indicator
1534
+ }
1535
+ break
1536
+ }
1537
+ ```
1538
+
1539
+ ### 8.3 Final Response (after session.idle)
1540
+
1541
+ When `session.idle` fires:
1542
+ 1. Stop draft stream
1543
+ 2. If response exceeds 4096 chars: delete draft, send chunked final response
1544
+ 3. If draft message exists and final text fits: edit to final formatted version
1545
+ 4. Clear turn
1546
+
1547
+ ### 8.4 Phase 3 TDD Order
1548
+
1549
+ ```
1550
+ RED+GREEN: send/draft-stream.test.ts → send/draft-stream.ts
1551
+ ─────────────────────────────────
1552
+ bun test → all unit green
1553
+ E2E: phase-3.test.ts → real validation
1554
+ ```
1555
+
1556
+ ### 8.5 Phase 3 Acceptance Criteria
1557
+
1558
+ - [ ] Response streams via message edits (text grows over time)
1559
+ - [ ] Tool call progress shown in chat
1560
+ - [ ] Final response properly formatted after stream ends
1561
+ - [ ] Long streamed responses get chunked correctly
1562
+ - [ ] `bun test` passes
1563
+ - [ ] `bun test e2e/phase-3.test.ts` passes
1564
+ - [ ] All Phase 0-2 E2E tests still pass
1565
+
1566
+ ---
1567
+
1568
+ ## 9. Phase 4 — Session Management
1569
+
1570
+ ### 9.1 Commands
1571
+
1572
+ | Command | SDK Call | Description |
1573
+ |---------|----------|-------------|
1574
+ | `/new` | `session.create()` | New session, clear mapping |
1575
+ | `/list` | `session.list()` | Show sessions with inline keyboard |
1576
+ | `/rename <title>` | `session.update({ title })` | Rename current session |
1577
+ | `/delete` | `session.delete()` | Delete current session |
1578
+ | `/history` | `session.messages()` | Show recent messages |
1579
+ | `/summarize` | `session.summarize()` | Summarize session |
1580
+ | `/info` | `session.get()` | Show session info |
1581
+
1582
+ ### 9.2 Session Selection (inline keyboard)
1583
+
1584
+ ```ts
1585
+ bot.command("list", async (ctx) => {
1586
+ const sessions = await sdk.session.list().then(r => r.data ?? [])
1587
+ const active = sessions
1588
+ .filter(s => !s.time.archived)
1589
+ .sort((a, b) => b.time.updated - a.time.updated)
1590
+ .slice(0, 10)
1591
+
1592
+ const rows = active.map(s => [{
1593
+ text: `${s.title || s.id.slice(0, 8)} (${new Date(s.time.updated).toLocaleDateString()})`,
1594
+ callback_data: `sess:${s.id.slice(0, 20)}`, // fit in 64 bytes
1595
+ }])
1596
+
1597
+ await ctx.reply("Select a session:", { reply_markup: { inline_keyboard: rows } })
1598
+ })
1599
+
1600
+ // Callback:
1601
+ if (data.startsWith("sess:")) {
1602
+ const sessionPrefix = data.slice(5)
1603
+ const sessions = await sdk.session.list().then(r => r.data ?? [])
1604
+ const match = sessions.find(s => s.id.startsWith(sessionPrefix))
1605
+ if (match) {
1606
+ sessionManager.set(chatKey, { sessionId: match.id, ... })
1607
+ await ctx.editMessageText(`Switched to: ${match.title || match.id}`)
1608
+ }
1609
+ }
1610
+ ```
1611
+
1612
+ ### 9.3 Phase 4 Acceptance Criteria
1613
+
1614
+ - [ ] `/new` creates new session and maps to chat
1615
+ - [ ] `/list` shows sessions with inline keyboard
1616
+ - [ ] Clicking session button switches active session
1617
+ - [ ] `/rename` changes session title
1618
+ - [ ] `/delete` removes session
1619
+ - [ ] `/history` shows recent messages
1620
+ - [ ] `bun test` passes
1621
+ - [ ] `bun test e2e/phase-4.test.ts` passes
1622
+ - [ ] All Phase 0-3 E2E tests still pass
1623
+
1624
+ ---
1625
+
1626
+ ## 10. Phase 5 — Model & Agent Selection
1627
+
1628
+ ### 10.1 Model Selection (adapted from OpenClaw's model-buttons.ts)
1629
+
1630
+ ```ts
1631
+ bot.command("model", async (ctx) => {
1632
+ const providers = await sdk.provider.list().then(r => r.data)
1633
+ // Group models by provider
1634
+ // Show provider keyboard (2 per row)
1635
+ // On provider select → show models (paginated, 8 per page)
1636
+ // On model select → store override for this chat
1637
+ // Same callback_data pattern as OpenClaw: mdl_prov, mdl_list_X_Y, mdl_sel_X/Y, mdl_back
1638
+ })
1639
+ ```
1640
+
1641
+ ### 10.2 Agent Selection
1642
+
1643
+ ```ts
1644
+ bot.command("agent", async (ctx) => {
1645
+ const agents = await sdk.app.agents().then(r => r.data ?? [])
1646
+ const rows = agents.map(a => [{
1647
+ text: a.name,
1648
+ callback_data: `agent:${a.name}`,
1649
+ }])
1650
+ await ctx.reply("Select an agent:", { reply_markup: { inline_keyboard: rows } })
1651
+ })
1652
+ ```
1653
+
1654
+ ### 10.3 Per-Chat Overrides
1655
+
1656
+ Store in SessionManager (already in memory, evicted with session):
1657
+ ```ts
1658
+ type SessionEntry = {
1659
+ sessionId: string
1660
+ directory: string
1661
+ agent?: string // override
1662
+ model?: string // "provider/model" override
1663
+ variant?: string // "high" | "max" | undefined
1664
+ createdAt: number
1665
+ lastAccessAt: number
1666
+ }
1667
+ ```
1668
+
1669
+ ### 10.4 Phase 5 Acceptance Criteria
1670
+
1671
+ - [ ] `/model` shows provider list with inline keyboard
1672
+ - [ ] Selecting provider shows paginated model list
1673
+ - [ ] Selecting model stores override for chat
1674
+ - [ ] `/agent` shows agent list with inline keyboard
1675
+ - [ ] Next prompt uses selected model/agent
1676
+ - [ ] `bun test` passes
1677
+ - [ ] `bun test e2e/phase-5.test.ts` passes
1678
+ - [ ] All Phase 0-4 E2E tests still pass
1679
+
1680
+ ---
1681
+
1682
+ ## 11. Phase 6 — Media & Files
1683
+
1684
+ ### 11.1 Photo/Document → FilePartInput
1685
+
1686
+ ```ts
1687
+ async function downloadTelegramFile(ctx: Context, token: string): Promise<{
1688
+ buffer: Buffer
1689
+ mime: string
1690
+ filename: string
1691
+ } | null> {
1692
+ const msg = ctx.message
1693
+ const fileRef = msg.photo?.[msg.photo.length - 1] // highest res
1694
+ ?? msg.document
1695
+ ?? msg.audio
1696
+ ?? msg.voice
1697
+ ?? msg.video
1698
+
1699
+ if (!fileRef?.file_id) return null
1700
+
1701
+ const file = await ctx.api.getFile(fileRef.file_id)
1702
+ if (!file.file_path) return null
1703
+
1704
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`
1705
+ const res = await fetch(url)
1706
+ const buffer = Buffer.from(await res.arrayBuffer())
1707
+ const mime = res.headers.get("content-type") ?? "application/octet-stream"
1708
+ const filename = (msg.document?.file_name) ?? file.file_path.split("/").pop() ?? "file"
1709
+
1710
+ return { buffer, mime, filename }
1711
+ }
1712
+
1713
+ // Convert to data URL for SDK:
1714
+ function bufferToDataUrl(buffer: Buffer, mime: string): string {
1715
+ return `data:${mime};base64,${buffer.toString("base64")}`
1716
+ }
1717
+
1718
+ // In message handler:
1719
+ const media = await downloadTelegramFile(ctx, config.botToken)
1720
+ if (media) {
1721
+ parts.push({
1722
+ id: generateId(),
1723
+ type: "file",
1724
+ mime: media.mime,
1725
+ url: bufferToDataUrl(media.buffer, media.mime),
1726
+ filename: media.filename,
1727
+ })
1728
+ }
1729
+ ```
1730
+
1731
+ ### 11.2 Voice Messages
1732
+
1733
+ Same download flow, but mime will be `audio/ogg` (Telegram voice format).
1734
+ Can be sent as-is to OpenCode — the AI can describe audio files.
1735
+
1736
+ ### 11.3 Phase 6 Acceptance Criteria
1737
+
1738
+ - [ ] Sending photo → bot processes and responds about image
1739
+ - [ ] Sending document → bot processes file content
1740
+ - [ ] Sending voice message → bot receives audio
1741
+ - [ ] Media groups handled (multiple photos)
1742
+ - [ ] `bun test` passes
1743
+ - [ ] `bun test e2e/phase-6.test.ts` passes
1744
+ - [ ] All Phase 0-5 E2E tests still pass
1745
+
1746
+ ---
1747
+
1748
+ ## 12. Phase 7 — Power Features
1749
+
1750
+ ### 12.1 OpenCode Slash Commands
1751
+
1752
+ ```ts
1753
+ // Fetch available commands:
1754
+ const commands = await sdk.command.list().then(r => r.data ?? [])
1755
+
1756
+ // Register as Telegram commands:
1757
+ await bot.api.setMyCommands([
1758
+ ...commands.map(c => ({ command: c.name, description: c.description ?? c.name })),
1759
+ { command: "new", description: "New session" },
1760
+ { command: "cancel", description: "Cancel generation" },
1761
+ { command: "model", description: "Select model" },
1762
+ // ...
1763
+ ])
1764
+
1765
+ // Handle via session.command():
1766
+ for (const cmd of commands) {
1767
+ bot.command(cmd.name, async (ctx) => {
1768
+ const args = ctx.match?.trim() ?? ""
1769
+ const entry = await sessionManager.getOrCreate(chatKey, sdk)
1770
+ await sdk.session.command({
1771
+ sessionID: entry.sessionId,
1772
+ command: cmd.name,
1773
+ arguments: args,
1774
+ agent: entry.agent ?? "build",
1775
+ model: entry.model ?? undefined,
1776
+ })
1777
+ })
1778
+ }
1779
+ ```
1780
+
1781
+ ### 12.2 Shell Mode
1782
+
1783
+ ```ts
1784
+ // Messages starting with ! are shell commands:
1785
+ if (text.startsWith("!")) {
1786
+ const command = text.slice(1).trim()
1787
+ await sdk.session.shell({
1788
+ sessionID: entry.sessionId,
1789
+ command,
1790
+ agent: entry.agent ?? "build",
1791
+ model: { providerID: "...", modelID: "..." },
1792
+ })
1793
+ return
1794
+ }
1795
+ ```
1796
+
1797
+ ### 12.3 Session Features
1798
+
1799
+ ```ts
1800
+ bot.command("diff", async (ctx) => {
1801
+ const entry = sessionManager.get(chatKey)
1802
+ if (!entry) return ctx.reply("No session.")
1803
+ const diff = await sdk.session.diff({ id: entry.sessionId }).then(r => r.data)
1804
+ // Format diffs as code blocks
1805
+ })
1806
+
1807
+ bot.command("todo", async (ctx) => {
1808
+ const entry = sessionManager.get(chatKey)
1809
+ if (!entry) return ctx.reply("No session.")
1810
+ const todos = await sdk.session.todo({ id: entry.sessionId }).then(r => r.data)
1811
+ // Format as checklist
1812
+ })
1813
+
1814
+ bot.command("undo", async (ctx) => {
1815
+ /* session.revert() */
1816
+ })
1817
+
1818
+ bot.command("redo", async (ctx) => {
1819
+ /* session.unrevert() */
1820
+ })
1821
+
1822
+ bot.command("fork", async (ctx) => {
1823
+ /* session.fork() — creates new session, updates mapping */
1824
+ })
1825
+
1826
+ bot.command("share", async (ctx) => {
1827
+ const entry = sessionManager.get(chatKey)
1828
+ const result = await sdk.session.share({ id: entry.sessionId })
1829
+ await ctx.reply(`Share URL: ${result.data.share.url}`)
1830
+ })
1831
+ ```
1832
+
1833
+ ### 12.4 Phase 7 Acceptance Criteria
1834
+
1835
+ - [ ] OpenCode slash commands appear in Telegram command menu
1836
+ - [ ] `/diff` shows file changes
1837
+ - [ ] `/todo` shows task list
1838
+ - [ ] `/undo` reverts last change
1839
+ - [ ] `!echo test` executes shell command
1840
+ - [ ] `/fork` creates new session from current
1841
+ - [ ] `/share` returns share URL
1842
+ - [ ] `bun test` passes
1843
+ - [ ] `bun test e2e/phase-7.test.ts` passes
1844
+ - [ ] All Phase 0-6 E2E tests still pass
1845
+
1846
+ ---
1847
+
1848
+ ## 13. Phase 8 — Group & Forum
1849
+
1850
+ ### 13.1 Group Chat
1851
+
1852
+ ```ts
1853
+ // chatKey includes thread for forums:
1854
+ function buildChatKey(msg: Message): string {
1855
+ const chatId = msg.chat.id
1856
+ const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"
1857
+ const isForum = msg.chat.is_forum === true
1858
+ const threadId = isForum ? (msg.message_thread_id ?? 1) : undefined
1859
+
1860
+ if (isGroup && threadId != null) {
1861
+ return `${chatId}:topic:${threadId}`
1862
+ }
1863
+ return String(chatId)
1864
+ }
1865
+
1866
+ // Mention detection:
1867
+ function isBotMentioned(msg: Message, botUsername: string): boolean {
1868
+ const text = (msg.text ?? msg.caption ?? "").toLowerCase()
1869
+ if (text.includes(`@${botUsername.toLowerCase()}`)) return true
1870
+ // Also check reply to bot message
1871
+ if (msg.reply_to_message?.from?.is_bot) return true
1872
+ return false
1873
+ }
1874
+
1875
+ // In group handler:
1876
+ if (isGroup) {
1877
+ if (!isBotMentioned(msg, bot.botInfo.username)) return // ignore non-mentions
1878
+ }
1879
+ ```
1880
+
1881
+ ### 13.2 Forum Topics
1882
+
1883
+ Each forum topic gets its own session via the chatKey that includes the topic ID.
1884
+ This maps naturally to the SessionManager — each `chatId:topic:N` gets a separate session.
1885
+
1886
+ ### 13.3 Phase 8 Acceptance Criteria
1887
+
1888
+ - [ ] Bot responds to @mentions in group chat
1889
+ - [ ] Bot ignores non-mention messages in group
1890
+ - [ ] Forum topics get separate sessions
1891
+ - [ ] Different topics maintain independent conversations
1892
+ - [ ] `bun test` passes
1893
+ - [ ] `bun test e2e/phase-8.test.ts` passes
1894
+ - [ ] All Phase 0-7 E2E tests still pass
1895
+
1896
+ ---
1897
+
1898
+ ## 14. Phase 9 — Infrastructure & Security
1899
+
1900
+ ### 14.1 Webhook Mode
1901
+
1902
+ ```ts
1903
+ // Alternative to polling, for production:
1904
+ import { webhookCallback } from "grammy"
1905
+
1906
+ const app = express()
1907
+ app.post("/webhook", webhookCallback(bot, "express"))
1908
+ app.get("/health", (_, res) => res.send("ok"))
1909
+ app.listen(config.webhookPort)
1910
+
1911
+ await bot.api.setWebhook(config.webhookUrl, {
1912
+ secret_token: config.webhookSecret,
1913
+ })
1914
+ ```
1915
+
1916
+ ### 14.2 Access Control
1917
+
1918
+ ```ts
1919
+ // Simple allowlist:
1920
+ const allowedUsers = new Set(config.allowedUserIds)
1921
+
1922
+ bot.use(async (ctx, next) => {
1923
+ const userId = ctx.from?.id
1924
+ if (allowedUsers.size > 0 && (!userId || !allowedUsers.has(userId))) {
1925
+ return // silently ignore
1926
+ }
1927
+ await next()
1928
+ })
1929
+ ```
1930
+
1931
+ ### 14.3 Graceful Shutdown
1932
+
1933
+ ```ts
1934
+ const shutdown = async () => {
1935
+ eventBus.stop() // close SSE
1936
+ turnManager.abortAll() // abort all active turns
1937
+ await bot.stop() // stop Grammy polling
1938
+ process.exit(0)
1939
+ }
1940
+
1941
+ process.on("SIGINT", shutdown)
1942
+ process.on("SIGTERM", shutdown)
1943
+ ```
1944
+
1945
+ ### 14.4 Health Monitoring
1946
+
1947
+ ```ts
1948
+ // Periodic health check:
1949
+ setInterval(async () => {
1950
+ const health = await sdk.global.health().then(r => r.data).catch(() => null)
1951
+ if (!health?.healthy) {
1952
+ console.error("OpenCode server unhealthy")
1953
+ // Could notify admin via Telegram
1954
+ }
1955
+ }, 60_000)
1956
+ ```
1957
+
1958
+ ### 14.5 Phase 9 Acceptance Criteria
1959
+
1960
+ - [ ] Webhook mode works as alternative to polling
1961
+ - [ ] Allowlist blocks unauthorized users
1962
+ - [ ] Graceful shutdown cleans up all resources
1963
+ - [ ] Health monitoring detects OpenCode server issues
1964
+ - [ ] `bun test` passes
1965
+ - [ ] `bun test e2e/phase-9.test.ts` passes
1966
+ - [ ] All Phase 0-8 E2E tests still pass (FULL REGRESSION)
1967
+
1968
+ ---
1969
+
1970
+ ## 15. Dependencies
1971
+
1972
+ ```json
1973
+ {
1974
+ "dependencies": {
1975
+ "grammy": "^1.x",
1976
+ "@grammyjs/runner": "^2.x",
1977
+ "@grammyjs/transformer-throttler": "^1.x",
1978
+ "@opencode-ai/sdk": "workspace:*"
1979
+ },
1980
+ "devDependencies": {
1981
+ "telegram": "^2.x",
1982
+ "@types/bun": "latest"
1983
+ }
1984
+ }
1985
+ ```
1986
+
1987
+ - **grammy** — Telegram Bot Framework (same as OpenClaw)
1988
+ - **@grammyjs/runner** — concurrent update processing
1989
+ - **@grammyjs/transformer-throttler** — Telegram API rate limit protection
1990
+ - **@opencode-ai/sdk** — OpenCode SDK client
1991
+ - **telegram (gramjs)** — MTProto client for E2E tests (userbot)
1992
+
1993
+ ---
1994
+
1995
+ ## 16. Key Differences from OpenClaw
1996
+
1997
+ | Aspect | OpenClaw | Our Bot |
1998
+ |--------|----------|---------|
1999
+ | AI Backend | Direct agent integration | SDK HTTP client |
2000
+ | Session Store | Custom file-based store | OpenCode server manages |
2001
+ | Streaming | Token-by-token from agent | SSE `message.part.updated` |
2002
+ | Config | OpenClaw YAML config | Simple env vars / JSON |
2003
+ | Commands | Custom command registry | `command.list()` from SDK |
2004
+ | Permissions | N/A (agent has full access) | Full permission system |
2005
+ | Questions | N/A | Full question system |
2006
+ | Multi-Project | N/A | `x-opencode-directory` header |
2007
+ | Complexity | ~6,500 LOC | Target: ~2,000 LOC (Phase 1: ~500) |
2008
+
2009
+ ---
2010
+
2011
+ ## 17. SDK Method Quick Reference
2012
+
2013
+ ```ts
2014
+ // Session
2015
+ sdk.session.create()
2016
+ sdk.session.list()
2017
+ sdk.session.get({ path: { id } })
2018
+ sdk.session.update({ path: { id }, body: { title } })
2019
+ sdk.session.delete({ path: { id } })
2020
+ sdk.session.abort({ sessionID })
2021
+ sdk.session.fork({ path: { id }, body: { messageID } })
2022
+ sdk.session.share({ path: { id } })
2023
+ sdk.session.unshare({ path: { id } })
2024
+ sdk.session.prompt({ path: { id }, body: { parts, agent, model, variant, messageID } })
2025
+ sdk.session.shell({ path: { id }, body: { command, agent, model } })
2026
+ sdk.session.command({ path: { id }, body: { command, arguments, agent, model, variant } })
2027
+ sdk.session.messages({ path: { id }, query: { cursor? } })
2028
+ sdk.session.diff({ path: { id } })
2029
+ sdk.session.todo({ path: { id } })
2030
+ sdk.session.revert({ path: { id }, body: { messageID } })
2031
+ sdk.session.unrevert({ path: { id } })
2032
+ sdk.session.summarize({ path: { id } })
2033
+ sdk.session.status()
2034
+
2035
+ // Permissions
2036
+ sdk.permission.list()
2037
+ sdk.permission.respond({ id, sessionID, reply })
2038
+
2039
+ // Questions
2040
+ sdk.question.list()
2041
+ sdk.question.reply({ id, sessionID, answers })
2042
+ sdk.question.reject({ id, sessionID })
2043
+
2044
+ // Events
2045
+ sdk.event.subscribe() // per-instance SSE
2046
+
2047
+ // App
2048
+ sdk.app.agents()
2049
+ sdk.provider.list()
2050
+ sdk.provider.auth()
2051
+ sdk.command.list()
2052
+ sdk.config.get()
2053
+ sdk.global.health()
2054
+ sdk.vcs.get()
2055
+ ```