@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
@@ -0,0 +1,485 @@
1
+ # Phase 3 — Streaming + UX
2
+
3
+ **Goal:** Make responses feel responsive and informative. Instead of waiting for `session.idle`
4
+ to send a single complete message, stream the response in real-time via Telegram `editMessageText`,
5
+ and show tool call progress during generation.
6
+
7
+ ## What This Phase Delivers
8
+
9
+ 1. **Draft Streaming** — When the first text part arrives via SSE, send a placeholder message.
10
+ Then throttle-edit it as more text streams in (400ms minimum between edits). This replaces
11
+ the current behavior where the bot waits for `session.idle` before sending any response.
12
+
13
+ 2. **Tool Call Progress** — When `message.part.updated` events arrive with `part.type === "tool"`,
14
+ append a status line to the draft (e.g., `\n\n---\n⚙ Running bash: ls -la`). When the tool
15
+ completes, the status line is removed and the next text update replaces it.
16
+
17
+ 3. **Final Response** — When `session.idle` fires, stop the draft stream and send the properly
18
+ formatted final response. If the final text fits in the existing draft message (≤4096 chars),
19
+ do a final edit. If it exceeds 4096 chars, delete the draft and send chunked final messages.
20
+
21
+ ## Current Behavior vs New Behavior
22
+
23
+ **Current (Phase 1-2):**
24
+ ```
25
+ User sends message
26
+ → EventBus accumulates text parts in turn.accumulatedText
27
+ → On session.idle: sendFormattedResponse(chatId, accumulatedText)
28
+ → User sees nothing until the AI finishes
29
+ ```
30
+
31
+ **New (Phase 3):**
32
+ ```
33
+ User sends message
34
+ → First text part arrives: DraftStream sends initial message
35
+ → More text: DraftStream edits message (throttled at 400ms)
36
+ → Tool starts: DraftStream appends tool status line
37
+ → Tool completes: status line removed, text continues
38
+ → session.idle: DraftStream stops, final formatted response sent
39
+ ```
40
+
41
+ ## Architecture
42
+
43
+ ```
44
+ TurnManager.start()
45
+ → Creates ActiveTurn with AbortController
46
+ → DraftStream created in bot.ts Grammy handler after handleMessage
47
+
48
+ EventBus.onEvent("message.part.updated", type: "text")
49
+ → turn.accumulatedText = part.text (full text, not delta)
50
+ → turn.toolSuffix = "" (clear tool suffix)
51
+ → turn.draft.update(part.text)
52
+
53
+ EventBus.onEvent("message.part.updated", type: "tool")
54
+ → turn.toolSuffix = formatToolStatus(part)
55
+ → turn.draft.update(turn.accumulatedText + toolSuffix)
56
+
57
+ EventBus.onEvent("session.idle")
58
+ → turn.draft.stop()
59
+ → Finalize: if text ≤ 4096 and draft exists:
60
+ edit draft to final formatted HTML
61
+ else if draft exists:
62
+ delete draft, send chunked final
63
+ else:
64
+ send normally (no draft was ever sent)
65
+ → turnManager.end(sessionId)
66
+
67
+ AbortSignal fires (turn end / cancel)
68
+ → DraftStream.stop() auto-called via signal listener
69
+ → All timers cleared
70
+ ```
71
+
72
+ ## DraftStream Class Design
73
+
74
+ ```ts
75
+ // src/send/draft-stream.ts
76
+
77
+ export type DraftStreamDeps = {
78
+ sendMessage: (chatId: number, text: string, opts?: any) => Promise<{ message_id: number }>
79
+ editMessageText: (chatId: number, messageId: number, text: string, opts?: any) => Promise<void>
80
+ }
81
+
82
+ export class DraftStream {
83
+ private messageId: number | null = null
84
+ private lastText = ""
85
+ private lastSentAt = 0
86
+ private pending = ""
87
+ private timer: ReturnType<typeof setTimeout> | null = null
88
+ private stopped = false
89
+ private flushing = false
90
+ private htmlFailed = false
91
+ readonly throttleMs: number
92
+
93
+ constructor(
94
+ private readonly deps: DraftStreamDeps,
95
+ private readonly chatId: number,
96
+ private readonly signal: AbortSignal,
97
+ throttleMs = 400,
98
+ ) {
99
+ this.throttleMs = throttleMs
100
+ signal.addEventListener("abort", () => this.stop(), { once: true })
101
+ }
102
+
103
+ async update(text: string): Promise<void>
104
+ private scheduleFlush(): void
105
+ private async flush(): Promise<void>
106
+ stop(): void
107
+ getMessageId(): number | null
108
+ isStopped(): boolean
109
+ hasHtmlFailed(): boolean
110
+ }
111
+ ```
112
+
113
+ **Key design decisions:**
114
+
115
+ 1. **Dependency injection for testability** — `DraftStreamDeps` abstracts `bot.api.sendMessage`
116
+ and `bot.api.editMessageText`. Tests inject mocks directly.
117
+
118
+ 2. **Full text replacement, not delta** — SDK sends full `part.text` on each update,
119
+ not incremental deltas. DraftStream always receives the complete current text.
120
+
121
+ 3. **Truncation during streaming** — During stream, text is truncated to 4096 chars for
122
+ the draft message. Full text is kept in `turn.accumulatedText` for final send.
123
+
124
+ 4. **HTML fallback tracking** — If `editMessageText` with `parse_mode: "HTML"` fails with
125
+ "can't parse entities", DraftStream switches to plain text for subsequent edits and sets
126
+ `htmlFailed = true`. Finalization tries HTML one more time on the complete text.
127
+
128
+ 5. **Flush mutex** — `flushing` flag prevents concurrent flush operations. If a flush is
129
+ in flight and another `update()` arrives, the new text is stored in `pending` and will
130
+ be picked up when the current flush completes.
131
+
132
+ 6. **AbortSignal integration** — `signal.addEventListener("abort", stop, { once: true })`
133
+ ensures automatic cleanup. No closures over Grammy context.
134
+
135
+ ## DraftStream.update() Flow
136
+
137
+ ```
138
+ update(text):
139
+ if stopped or text is empty → return
140
+ pending = text
141
+
142
+ if no messageId yet (first update):
143
+ truncate to 4096
144
+ convert to HTML
145
+ sendMessage → store messageId
146
+ set lastText, lastSentAt
147
+ return
148
+
149
+ scheduleFlush()
150
+
151
+ scheduleFlush():
152
+ if timer already set → return (will pick up latest pending)
153
+ delay = max(0, throttleMs - (now - lastSentAt))
154
+ timer = setTimeout(flush, delay)
155
+
156
+ flush():
157
+ timer = null
158
+ if stopped → return
159
+ flushing = true
160
+ text = pending.slice(0, 4096)
161
+ if text === lastText → flushing = false; return
162
+
163
+ try HTML edit:
164
+ if htmlFailed: plain text edit instead
165
+ else: convert to HTML, editMessageText with parse_mode: "HTML"
166
+ catch:
167
+ "message is not modified" → ignore
168
+ "can't parse entities" → htmlFailed = true, retry plain text
169
+ "MESSAGE_ID_INVALID" / "message to edit not found" → stopped = true
170
+ other → log, continue
171
+
172
+ lastText = text
173
+ lastSentAt = now
174
+ flushing = false
175
+
176
+ if pending changed during flush (new update arrived):
177
+ scheduleFlush() // re-schedule with latest
178
+ ```
179
+
180
+ ## Tool Progress Display
181
+
182
+ Tool progress is **appended to the draft text as a suffix**, not sent as separate messages.
183
+
184
+ ```ts
185
+ // src/send/tool-progress.ts
186
+
187
+ export function formatToolStatus(part: any): string | null {
188
+ if (part.type !== "tool") return null
189
+
190
+ const tool = part.tool ?? "tool"
191
+ const state = part.state
192
+
193
+ if (state?.status === "running" && state?.title) {
194
+ return `\n\n---\n⚙ Running ${tool}: ${state.title}`
195
+ }
196
+ if (state?.status === "pending") {
197
+ return `\n\n---\n⚙ Preparing ${tool}...`
198
+ }
199
+ // completed/error → no suffix (text will update)
200
+ return null
201
+ }
202
+ ```
203
+
204
+ ## Final Response (session.idle)
205
+
206
+ ```ts
207
+ async function finalizeResponse(chatId: number, turn: ActiveTurn) {
208
+ turn.draft?.stop()
209
+ const text = turn.accumulatedText
210
+ if (!text) return
211
+
212
+ const draftMsgId = turn.draft?.getMessageId() ?? null
213
+ const html = markdownToTelegramHtml(text)
214
+ const chunks = chunkMessage(html)
215
+
216
+ if (chunks.length === 1 && draftMsgId) {
217
+ // Single chunk — edit draft to final version
218
+ try {
219
+ await bot.api.editMessageText(chatId, draftMsgId, html, { parse_mode: "HTML" })
220
+ } catch (err) {
221
+ const msg = String(err)
222
+ if (/message is not modified/i.test(msg)) return
223
+ if (/can't parse entities/i.test(msg)) {
224
+ await bot.api.editMessageText(chatId, draftMsgId, text.slice(0, 4096))
225
+ return
226
+ }
227
+ if (/message to edit not found/i.test(msg) || /MESSAGE_ID_INVALID/i.test(msg)) {
228
+ await sendFormattedResponse(chatId, text)
229
+ return
230
+ }
231
+ throw err
232
+ }
233
+ } else if (draftMsgId) {
234
+ // Multiple chunks — delete draft and send all
235
+ await bot.api.deleteMessage(chatId, draftMsgId).catch(() => {})
236
+ await sendFormattedResponse(chatId, text)
237
+ } else {
238
+ // No draft sent — send normally
239
+ await sendFormattedResponse(chatId, text)
240
+ }
241
+ }
242
+ ```
243
+
244
+ ## ActiveTurn Changes
245
+
246
+ ```ts
247
+ export type ActiveTurn = {
248
+ sessionId: string
249
+ chatId: number
250
+ abortController: AbortController
251
+ accumulatedText: string
252
+ toolSuffix: string // NEW: current tool status suffix
253
+ timers: Set<ReturnType<typeof setTimeout>>
254
+ draft: DraftStream | null // NEW: streaming draft editor
255
+ }
256
+ ```
257
+
258
+ `draftMessageId` field from original spec is not needed separately — it lives on
259
+ the `DraftStream` instance via `turn.draft.getMessageId()`.
260
+
261
+ ## New Files
262
+
263
+ ```
264
+ src/
265
+ send/
266
+ draft-stream.ts ← DraftStream class
267
+ draft-stream.test.ts ← ~18 tests
268
+ tool-progress.ts ← formatToolStatus()
269
+ tool-progress.test.ts ← ~8 tests
270
+ e2e/
271
+ phase-3.test.ts ← 3 E2E tests
272
+ ```
273
+
274
+ ## Modified Files
275
+
276
+ ```
277
+ src/
278
+ turn-manager.ts ← Add toolSuffix + draft fields to ActiveTurn
279
+ turn-manager.test.ts ← 3 new tests for new fields
280
+ bot.ts ← Create DraftStream in Grammy handler after handleMessage
281
+ index.ts ← Replace accumulate-then-send with DraftStream,
282
+ add tool progress, add finalizeResponse
283
+ ```
284
+
285
+ ## TDD Execution Order (bottom-up by dependency)
286
+
287
+ ### 1. send/tool-progress.ts — Pure formatting (8 tests)
288
+
289
+ Zero dependencies. Pure function.
290
+
291
+ **Tests:**
292
+ 1. Returns null for non-tool part (type: "text")
293
+ 2. Returns running status for tool with status "running" and title
294
+ 3. Returns pending status for tool with status "pending"
295
+ 4. Returns null for completed tool (no suffix needed)
296
+ 5. Returns null for error tool (no suffix needed)
297
+ 6. Running status includes tool name
298
+ 7. Running status includes title text
299
+ 8. Returns null when part.state is undefined
300
+
301
+ ### 2. send/draft-stream.ts — Core streaming class (18 tests)
302
+
303
+ Depends on: DraftStreamDeps (injected mocks).
304
+
305
+ **Tests:**
306
+
307
+ *Initial send:*
308
+ 1. First update() sends a new message via sendMessage
309
+ 2. First update() stores messageId from sendMessage response
310
+ 3. First update() truncates text to 4096 chars
311
+ 4. First update() sends HTML formatted text
312
+
313
+ *Throttled edits:*
314
+ 5. Second update() schedules an edit (not immediate)
315
+ 6. After throttle delay, editMessageText is called with updated text
316
+ 7. Multiple rapid updates only result in one edit (latest text wins)
317
+ 8. Edit uses HTML parse_mode
318
+
319
+ *Error handling:*
320
+ 9. "message is not modified" error is silently ignored
321
+ 10. "can't parse entities" falls back to plain text edit
322
+ 11. After HTML failure, subsequent edits use plain text
323
+ 12. "message to edit not found" stops the stream
324
+ 13. sendMessage failure on first update → messageId stays null
325
+
326
+ *stop():*
327
+ 14. stop() clears pending timer
328
+ 15. After stop(), update() is a no-op
329
+ 16. stop() sets isStopped() to true
330
+
331
+ *AbortSignal:*
332
+ 17. Aborting signal calls stop() automatically
333
+ 18. Already-aborted signal stops immediately on construction
334
+
335
+ ### 3. Modified: turn-manager.ts — New fields (3 new tests)
336
+
337
+ **Changes:**
338
+ - Add `toolSuffix: string` (default `""`) and `draft: DraftStream | null` (default `null`)
339
+ - `end()` calls `turn.draft?.stop()` before abort
340
+
341
+ **New tests:**
342
+ 1. New turn has toolSuffix="" and draft=null
343
+ 2. Draft can be set on turn and accessed
344
+ 3. end() calls draft.stop() if draft exists
345
+
346
+ ### 4. Modified: bot.ts — Create DraftStream in Grammy handler
347
+
348
+ ```ts
349
+ bot.on("message:text", async (ctx) => {
350
+ const chatId = ctx.chat.id
351
+ const text = ctx.message.text.trim()
352
+ if (!text) return
353
+
354
+ const { turn } = await handleMessage({ chatId, text, sdk, sessionManager, turnManager })
355
+
356
+ // Start streaming draft
357
+ turn.draft = new DraftStream(
358
+ {
359
+ sendMessage: (id, t, o) => bot.api.sendMessage(id, t, o),
360
+ editMessageText: (id, m, t, o) => bot.api.editMessageText(id, m, t, o),
361
+ },
362
+ chatId,
363
+ turn.abortController.signal,
364
+ )
365
+
366
+ startTypingLoop(chatId, (id, action) => bot.api.sendChatAction(id, action), turn.abortController.signal)
367
+ })
368
+ ```
369
+
370
+ ### 5. Modified: index.ts — Wire draft updates, tool progress, finalization
371
+
372
+ **Changes to `onEvent` handler:**
373
+ - `message.part.updated` type "text": also call `turn.draft?.update(part.text)`
374
+ - `message.part.updated` type "tool": call `formatToolStatus(part)`, update draft
375
+ - `session.idle`: call `finalizeResponse()` instead of `sendFormattedResponse()`
376
+
377
+ ### 6. Validate: `bun test` → all unit green (~130+ tests)
378
+
379
+ ### 7. E2E: phase-3.test.ts → real validation
380
+
381
+ ## E2E Tests
382
+
383
+ ```ts
384
+ describe("Phase 3 — Streaming + UX", () => {
385
+ test("response streams via message edits (text grows)", async () => {
386
+ const client = getClient()
387
+ const bot = getBotUsername()
388
+
389
+ await client.sendMessage(bot, { message: "Explain what TypeScript is in 3 paragraphs" })
390
+
391
+ // Wait for initial draft
392
+ await sleep(3000)
393
+ const messages1 = await client.getMessages(bot, { limit: 1 })
394
+ const msg1 = messages1[0]
395
+ const text1 = msg1.text ?? msg1.message ?? ""
396
+
397
+ // Wait for more streaming
398
+ await sleep(5000)
399
+ const messages2 = await client.getMessages(bot, { ids: [msg1.id] })
400
+ const msg2 = messages2[0]
401
+ const text2 = msg2.text ?? msg2.message ?? ""
402
+
403
+ // Same message ID (edit, not new message), text grew or stayed
404
+ expect(msg2.id).toBe(msg1.id)
405
+ expect(text2.length).toBeGreaterThanOrEqual(text1.length)
406
+ }, 60000)
407
+
408
+ test("regression: /start still works", async () => {
409
+ const reply = await sendAndWait(getClient(), getBotUsername(), "/start")
410
+ assertContains(reply, "OpenCode Telegram Bot")
411
+ }, 20000)
412
+
413
+ test("regression: text message gets AI response", async () => {
414
+ const reply = await sendAndWait(
415
+ getClient(), getBotUsername(),
416
+ "Say exactly the word hello and nothing else",
417
+ 60000,
418
+ )
419
+ assertContains(reply, /hello/i)
420
+ }, 90000)
421
+ })
422
+ ```
423
+
424
+ **Note:** E2E streaming test is inherently timing-dependent. The test verifies core behavior
425
+ (message is edited in-place) but does not assert strict intermediate growth, since fast AI
426
+ responses may complete before the second poll.
427
+
428
+ ## Edge Cases
429
+
430
+ ### Race: session.idle before first text part
431
+ - `turn.accumulatedText` will be empty → `finalizeResponse` returns early
432
+ - No draft was sent, no message to clean up
433
+
434
+ ### Race: DraftStream.update() and DraftStream.stop()
435
+ - `stop()` sets `stopped = true` and clears timer
436
+ - `flush()` mid-flight checks `stopped` before editing
437
+ - `update()` after `stop()` returns early
438
+
439
+ ### Race: Multiple rapid text updates
440
+ - Each `update()` sets `pending` to latest text (latest-wins)
441
+ - Timer is set only once, `flush()` uses latest `pending`
442
+
443
+ ### Text exceeds 4096 during streaming
444
+ - Draft truncated to 4096 chars for display
445
+ - Full text in `turn.accumulatedText` for final send
446
+ - On `session.idle`, `finalizeResponse` chunks properly
447
+
448
+ ### User deletes the draft message
449
+ - `editMessageText` fails → DraftStream stops
450
+ - `finalizeResponse` tries edit, same error, falls back to `sendFormattedResponse`
451
+
452
+ ### Tool progress after text
453
+ - Tool suffix appended to display only, not stored in `accumulatedText`
454
+ - Next text part clears `toolSuffix`
455
+
456
+ ### HTML parse error during streaming
457
+ - DraftStream switches to plain text (`htmlFailed = true`)
458
+ - Finalization tries HTML one more time on complete text
459
+
460
+ ### Turn cancelled (/cancel) during streaming
461
+ - AbortSignal fires → DraftStream.stop() auto-called
462
+ - Draft message stays in chat (partially streamed)
463
+ - No cleanup of draft message (user sees what was generated)
464
+
465
+ ## Acceptance Criteria
466
+
467
+ - [ ] Response streams via message edits (text grows over time in same message)
468
+ - [ ] Tool call progress shown as status line in draft
469
+ - [ ] Final response properly formatted after stream ends (HTML)
470
+ - [ ] Long streamed responses get chunked correctly on finalize
471
+ - [ ] Streaming respects 400ms throttle between edits
472
+ - [ ] "message is not modified" errors handled silently
473
+ - [ ] "can't parse entities" falls back to plain text
474
+ - [ ] "message to edit not found" stops streaming gracefully
475
+ - [ ] AbortSignal stops draft stream automatically
476
+ - [ ] No memory leaks (timers cleared, no dangling references)
477
+ - [ ] `bun test` passes (all unit tests, including regression)
478
+ - [ ] `bun test ./e2e/phase-3.test.ts` passes
479
+ - [ ] All Phase 0-2 E2E tests still pass (regression)
480
+
481
+ ## Estimated Scope
482
+
483
+ - 2 new source files + 2 test files + 1 E2E test file
484
+ - ~250-300 LOC (src) + ~350-400 LOC (tests)
485
+ - Modified: turn-manager.ts, turn-manager.test.ts, bot.ts, index.ts