@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.
- package/.env.example +17 -0
- package/bunfig.toml +2 -0
- package/docs/PROGRESS.md +327 -0
- package/docs/mapping.md +326 -0
- package/docs/plans/phase-1.md +176 -0
- package/docs/plans/phase-2.md +235 -0
- package/docs/plans/phase-3.md +485 -0
- package/docs/plans/phase-4.md +566 -0
- package/docs/spec.md +2055 -0
- package/e2e/client.ts +24 -0
- package/e2e/helpers.ts +119 -0
- package/e2e/phase-0.test.ts +30 -0
- package/e2e/phase-1.test.ts +48 -0
- package/e2e/phase-2.test.ts +54 -0
- package/e2e/phase-3.test.ts +142 -0
- package/e2e/phase-4.test.ts +96 -0
- package/e2e/runner.ts +145 -0
- package/package.json +14 -12
- package/scripts/gen-session.ts +49 -0
- package/src/bot.test.ts +301 -0
- package/src/bot.ts +91 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +15 -0
- package/src/event-bus.test.ts +175 -0
- package/src/handlers/allowlist.test.ts +60 -0
- package/src/handlers/allowlist.ts +33 -0
- package/src/handlers/cancel.test.ts +105 -0
- package/src/handlers/permissions.test.ts +72 -0
- package/src/handlers/questions.test.ts +107 -0
- package/src/handlers/sessions.test.ts +479 -0
- package/src/handlers/sessions.ts +202 -0
- package/src/handlers/typing.test.ts +60 -0
- package/src/index.ts +26 -0
- package/src/pending-requests.test.ts +64 -0
- package/src/send/chunker.test.ts +74 -0
- package/src/send/draft-stream.test.ts +229 -0
- package/src/send/format.test.ts +143 -0
- package/src/send/tool-progress.test.ts +70 -0
- package/src/session-manager.test.ts +198 -0
- package/src/session-manager.ts +23 -0
- package/src/turn-manager.test.ts +155 -0
- package/src/turn-manager.ts +5 -0
- 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
|
+
& < > → & < > (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: & < > → & < >
|
|
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
|
+
```
|