@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,176 @@
1
+ # Phase 1 — Core Loop (MVP)
2
+
3
+ **Goal:** Send a text message → OpenCode processes it → formatted response in Telegram.
4
+
5
+ ## What This Phase Delivers
6
+
7
+ The minimum viable bot: a user sends a message, OpenCode's AI processes it,
8
+ and the bot replies with a formatted response. This is the foundation everything
9
+ else builds on.
10
+
11
+ ## Architecture
12
+
13
+ ```
14
+ User sends text message
15
+ → bot.on("message")
16
+ → SessionManager.getOrCreate(chatId) → OpenCode session
17
+ → sdk.session.prompt({ parts: [{ type: "text", text }] })
18
+
19
+ [EventBus receives SSE events]
20
+ → message.part.updated (type: "text") → accumulate text
21
+ → session.idle → send final formatted response
22
+ → session.error → send error message
23
+ ```
24
+
25
+ ## New Files
26
+
27
+ ```
28
+ src/
29
+ sdk.ts ← SDK client factory (createOpencode wrapper)
30
+ sdk.test.ts
31
+ session-manager.ts ← LRU Map<chatKey, SessionEntry>
32
+ session-manager.test.ts
33
+ turn-manager.ts ← Per-turn lifecycle (AbortController)
34
+ turn-manager.test.ts
35
+ event-bus.ts ← Single SSE connection + dispatcher
36
+ event-bus.test.ts
37
+ send/
38
+ format.ts ← Markdown → Telegram HTML
39
+ format.test.ts
40
+ chunker.ts ← Split messages at 4096 chars
41
+ chunker.test.ts
42
+ e2e/
43
+ phase-1.test.ts
44
+ ```
45
+
46
+ ## Modified Files
47
+
48
+ ```
49
+ src/
50
+ bot.ts ← Add message handler, /new command
51
+ bot.test.ts ← Add tests for message handler
52
+ index.ts ← Initialize SDK, SessionManager, EventBus, TurnManager
53
+ ```
54
+
55
+ ## TDD Execution Order (bottom-up by dependency)
56
+
57
+ ### 1. send/format.ts — Markdown → Telegram HTML
58
+ Pure function, zero dependencies. Tests:
59
+ - Bold: `**text**` → `<b>text</b>`
60
+ - Italic: `*text*` → `<i>text</i>`
61
+ - Code: `` `text` `` → `<code>text</code>`
62
+ - Code block: ``` ```ts\ncode``` ``` → `<pre><code class="language-ts">code</code></pre>`
63
+ - Links: `[text](url)` → `<a href="url">text</a>`
64
+ - HTML escaping: `& < >` → `&amp; &lt; &gt;`
65
+ - Nested: `**bold and *italic***`
66
+ - Edge: empty string, already-escaped HTML
67
+
68
+ ### 2. send/chunker.ts — Message splitting
69
+ Pure function. Tests:
70
+ - Short message (< 4096) returns single chunk
71
+ - Long message splits at ~4096 boundary
72
+ - Never splits inside HTML tags
73
+ - Preserves unclosed tags across chunks (close + reopen)
74
+ - Empty string returns empty array
75
+
76
+ ### 3. session-manager.ts — LRU session map
77
+ Tests:
78
+ - `getOrCreate` creates session via SDK on first access
79
+ - `getOrCreate` returns cached on second access (SDK not called again)
80
+ - `get` returns entry by chatKey
81
+ - `getBySessionId` reverse lookup
82
+ - `remove` clears both maps
83
+ - Evicts oldest when maxEntries exceeded
84
+ - Expired entries cleaned up by TTL
85
+ - `set` allows manual session binding (for /list switch)
86
+
87
+ ### 4. turn-manager.ts — Turn lifecycle
88
+ Tests:
89
+ - `start` creates AbortController for session
90
+ - `get` returns active turn
91
+ - `end` aborts controller, clears timers, removes entry
92
+ - `addTimer` / end clears all timers
93
+ - `abortAll` cleans up everything
94
+ - No leak: ended turn has no remaining references
95
+
96
+ ### 5. event-bus.ts — SSE event routing
97
+ Tests (with mock SSE stream):
98
+ - Routes event to correct chatKey via SessionManager reverse lookup
99
+ - Ignores events for unknown sessionIds
100
+ - Calls onEvent with (sessionId, chatKey, event)
101
+ - Reconnects on stream end (mock reconnect)
102
+ - stop() cleans up (aborts, no more events)
103
+
104
+ ### 6. bot.ts — Message handler + /new
105
+ Tests:
106
+ - Text message calls sdk.session.prompt with correct parts
107
+ - /new command removes old session mapping, creates new one
108
+ - Message to unknown chat creates session automatically
109
+
110
+ ### 7. Integration: index.ts wiring
111
+ No unit test — validated by E2E.
112
+
113
+ ## E2E Tests (phase-1.test.ts)
114
+
115
+ ```ts
116
+ test("text message gets AI response", async () => {
117
+ const reply = await sendAndWait(client, BOT, "Say the word hello", 30000)
118
+ assertContains(reply, /hello/i)
119
+ })
120
+
121
+ test("/new creates fresh session", async () => {
122
+ const reply = await sendAndWait(client, BOT, "/new")
123
+ assertContains(reply, /session/i)
124
+ })
125
+
126
+ test("error is shown in chat", async () => {
127
+ // Depends on OpenCode server behavior
128
+ // May need specific prompt to trigger error
129
+ })
130
+ ```
131
+
132
+ ## Key Implementation Decisions
133
+
134
+ ### SDK initialization
135
+ Following Slack bot pattern — spawn local OpenCode server:
136
+ ```ts
137
+ import { createOpencode } from "@opencode-ai/sdk"
138
+ const opencode = await createOpencode({ port: 0 })
139
+ ```
140
+ This makes the bot self-contained. No external server needed.
141
+
142
+ ### Single SSE connection
143
+ One `opencode.client.event.subscribe()` for ALL sessions.
144
+ Events routed via SessionManager's reverse map (sessionId → chatKey).
145
+
146
+ ### Text accumulation
147
+ On `message.part.updated` with `part.type === "text"`, we replace (not append)
148
+ the accumulated text — the SDK sends the full text each time, not deltas.
149
+
150
+ ### Response delivery (Phase 1 — no streaming)
151
+ Wait for `session.idle`, then send the complete formatted response.
152
+ Draft streaming comes in Phase 3.
153
+
154
+ ### Anti-leak measures active from day 1
155
+ - SessionManager: LRU with maxEntries + TTL
156
+ - TurnManager: AbortController per turn, auto-cleanup on end
157
+ - EventBus: single connection, AbortController for shutdown
158
+ - No Grammy ctx stored in closures — extract chatId/text immediately
159
+
160
+ ## Acceptance Criteria
161
+
162
+ - [ ] Text message → prompt → SSE → formatted reply in chat
163
+ - [ ] `/new` creates fresh session
164
+ - [ ] Long response (>4096 chars) is chunked correctly
165
+ - [ ] Markdown converted to Telegram HTML
166
+ - [ ] HTML parse errors fall back to plain text
167
+ - [ ] Errors shown in chat
168
+ - [ ] `bun test src/` passes (all unit tests, including Phase 0)
169
+ - [ ] `bun test ./e2e/phase-1.test.ts` passes
170
+ - [ ] `bun test ./e2e/phase-0.test.ts` still passes (regression)
171
+
172
+ ## Estimated Scope
173
+
174
+ - ~6 new source files + 6 test files
175
+ - ~800-1000 LOC (src) + ~400-500 LOC (tests)
176
+ - Heaviest files: event-bus.ts, format.ts, session-manager.ts
@@ -0,0 +1,235 @@
1
+ # Phase 2 — Interactive Controls
2
+
3
+ **Goal:** Handle permissions, questions, and abort — without these the AI agent gets stuck
4
+ waiting for user input and the bot appears frozen.
5
+
6
+ ## What This Phase Delivers
7
+
8
+ 1. **Permission handling** — When `permission.asked` SSE event arrives, show inline buttons
9
+ (Allow / Always / Deny). When clicked, call `sdk.permission.reply()`.
10
+ 2. **Question handling** — When `question.asked` SSE event arrives, show inline buttons
11
+ with the choices. When clicked, call `sdk.question.reply()` or `sdk.question.reject()`.
12
+ 3. **`/cancel` command** — Abort generation via `sdk.session.abort({ sessionID })`.
13
+ 4. **Typing indicator** — Show "typing..." continuously during active turns.
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ SSE Events
19
+ → permission.asked
20
+ → formatPermissionMessage() → send with inline keyboard
21
+ → Store requestID in PendingRequests (for double-click protection + TTL)
22
+
23
+ → question.asked
24
+ → formatQuestionMessage() → send with inline keyboard
25
+ → Store requestID + options in PendingRequests (for index→label resolution)
26
+
27
+ User clicks button (callback_query)
28
+ → answerCallbackQuery() ALWAYS first
29
+ → Parse callback_data: "perm:{reply}:{requestID}" or "q:{requestID}:{index}"
30
+ → Look up PendingEntry (guard: expired → "This request has expired.")
31
+ → Call sdk.permission.reply() or sdk.question.reply()/reject()
32
+ → Edit original message to show the decision
33
+ → Delete from PendingRequests (idempotency)
34
+
35
+ /cancel command
36
+ → Look up session from SessionManager
37
+ → Look up turn from TurnManager
38
+ → Call sdk.session.abort({ sessionID })
39
+ → Call turnManager.end(sessionID)
40
+ → Reply "Generation cancelled."
41
+
42
+ Typing indicator
43
+ → startTypingLoop(chatId, sendAction, signal)
44
+ → sendChatAction("typing") every 4 seconds
45
+ → Tied to turn's AbortSignal (auto-stops on end/abort)
46
+ ```
47
+
48
+ ## SDK API (verified from sdk.gen.ts)
49
+
50
+ ```ts
51
+ // Permission — only requestID needed (no sessionID!)
52
+ sdk.permission.reply({ requestID, reply: "once" | "always" | "reject", message?: string })
53
+
54
+ // Question — only requestID needed
55
+ sdk.question.reply({ requestID, answers: [["selected_label"]] })
56
+ sdk.question.reject({ requestID })
57
+
58
+ // Abort — sessionID in path
59
+ sdk.session.abort({ sessionID })
60
+ ```
61
+
62
+ ## Callback Data Design (max 64 bytes)
63
+
64
+ ```
65
+ Permission buttons:
66
+ perm:once:{requestID} → 10 + 30 = ~40 bytes ✓
67
+ perm:always:{requestID} → 12 + 30 = ~42 bytes ✓
68
+ perm:deny:{requestID} → 10 + 30 = ~40 bytes ✓
69
+
70
+ Question buttons:
71
+ q:{requestID}:{index} → 2 + 30 + 1 + 2 = ~35 bytes ✓
72
+ q:{requestID}:skip → 2 + 30 + 1 + 4 = ~37 bytes ✓
73
+ ```
74
+
75
+ IDs: `per_` + 26 chars = 30 chars, `que_` + 26 chars = 30 chars. All fit comfortably.
76
+
77
+ ## PendingRequests Design
78
+
79
+ ```ts
80
+ type PendingEntry = {
81
+ type: "permission" | "question"
82
+ createdAt: number
83
+ // Only for questions: store options for index→label resolution
84
+ questions?: Array<{ options: Array<{ label: string }> }>
85
+ }
86
+ ```
87
+
88
+ Why PendingRequests exists:
89
+ - **Questions**: REQUIRED — callback_data only holds index, need to resolve to label
90
+ - **Permissions**: OPTIONAL but useful — double-click protection + TTL expiry guard
91
+ - Bounded at 200 entries, TTL 10 minutes
92
+
93
+ ## New Files
94
+
95
+ ```
96
+ src/
97
+ pending-requests.ts ← Bounded Map<requestID, PendingEntry> with TTL
98
+ pending-requests.test.ts ← 7 tests
99
+ handlers/
100
+ permissions.ts ← formatPermissionMessage(), parsePermissionCallback()
101
+ permissions.test.ts ← 8 tests
102
+ questions.ts ← formatQuestionMessage(), parseQuestionCallback(), resolveQuestionAnswer()
103
+ questions.test.ts ← 9 tests
104
+ cancel.ts ← handleCancel()
105
+ cancel.test.ts ← 5 tests
106
+ typing.ts ← startTypingLoop()
107
+ typing.test.ts ← 4 tests
108
+ e2e/
109
+ phase-2.test.ts ← 3 E2E tests
110
+ ```
111
+
112
+ ## Modified Files
113
+
114
+ ```
115
+ src/
116
+ bot.ts ← Add /cancel, callback_query handler, BotDeps.pendingRequests
117
+ bot.test.ts ← Update tests for new handlers
118
+ index.ts ← Wire PendingRequests, add permission/question event cases,
119
+ add typing indicator on turn start
120
+ ```
121
+
122
+ ## TDD Execution Order (bottom-up by dependency)
123
+
124
+ ### 1. pending-requests.ts — Bounded request map (7 tests)
125
+ Pure data structure, zero dependencies.
126
+
127
+ Tests:
128
+ 1. `set()` stores, `get()` retrieves
129
+ 2. `get()` returns undefined for unknown requestID
130
+ 3. `delete()` removes entry and returns true
131
+ 4. `delete()` returns false for unknown requestID
132
+ 5. Evicts oldest when maxEntries exceeded (bounded at 200)
133
+ 6. `get()` returns undefined for expired entries (after TTL)
134
+ 7. `cleanup()` removes all expired entries
135
+
136
+ ### 2. handlers/permissions.ts — Permission formatting + parsing (8 tests)
137
+ Pure functions, depends only on types.
138
+
139
+ Functions:
140
+ - `formatPermissionMessage(perm)` → `{ text, reply_markup }`
141
+ - `parsePermissionCallback(data)` → `{ requestID, reply }` | null
142
+
143
+ Tests:
144
+ 1. `formatPermissionMessage` text contains permission name
145
+ 2. `formatPermissionMessage` text contains patterns
146
+ 3. `formatPermissionMessage` returns keyboard with 3 buttons (Allow/Always/Deny)
147
+ 4. Callback data follows pattern `perm:{action}:{requestID}`
148
+ 5. `parsePermissionCallback("perm:once:per_abc123")` → `{ requestID, reply: "once" }`
149
+ 6. `parsePermissionCallback("perm:always:per_abc123")` → `{ reply: "always" }`
150
+ 7. `parsePermissionCallback("perm:deny:per_abc123")` → `{ reply: "reject" }`
151
+ 8. `parsePermissionCallback("invalid:data")` → null
152
+
153
+ ### 3. handlers/questions.ts — Question formatting + parsing (9 tests)
154
+ Pure functions, depends only on types.
155
+
156
+ Functions:
157
+ - `formatQuestionMessage(questionEvent)` → `{ text, reply_markup }`
158
+ - `parseQuestionCallback(data)` → `{ requestID, action, optionIndex? }` | null
159
+ - `resolveQuestionAnswer(optionIndex, pending)` → `string[]`
160
+
161
+ Tests:
162
+ 1. `formatQuestionMessage` returns question text in message
163
+ 2. `formatQuestionMessage` renders options as 1-per-row buttons
164
+ 3. `formatQuestionMessage` adds "Skip" button as last row
165
+ 4. Callback data: `q:{requestID}:{index}` for selection
166
+ 5. Callback data: `q:{requestID}:skip` for skip
167
+ 6. `parseQuestionCallback("q:que_abc:0")` → `{ requestID, action: "select", optionIndex: 0 }`
168
+ 7. `parseQuestionCallback("q:que_abc:skip")` → `{ requestID, action: "skip" }`
169
+ 8. `parseQuestionCallback("invalid")` → null
170
+ 9. `resolveQuestionAnswer(0, pending)` maps index to option label
171
+
172
+ ### 4. handlers/cancel.ts — /cancel command (5 tests)
173
+ Depends on SessionManager + TurnManager (existing).
174
+
175
+ Tests:
176
+ 1. Returns "No active session." if no session found
177
+ 2. Returns "Nothing running." if session exists but no active turn
178
+ 3. Calls `sdk.session.abort({ sessionID })` when turn is active
179
+ 4. Calls `turnManager.end(sessionID)` after abort
180
+ 5. Returns "Generation cancelled." on success
181
+
182
+ ### 5. handlers/typing.ts — Typing indicator loop (4 tests)
183
+ Pure function, depends only on AbortSignal.
184
+
185
+ Tests:
186
+ 1. Calls sendAction immediately on start
187
+ 2. Calls sendAction again after ~4 seconds (fake timers)
188
+ 3. Stops calling when signal is aborted
189
+ 4. Does not throw if sendAction rejects
190
+
191
+ ### 6. Modified: bot.ts — Callback query handler + /cancel + typing
192
+ - Extend BotDeps with `pendingRequests: PendingRequests`
193
+ - Add `bot.command("cancel", ...)` that calls handleCancel
194
+ - Add `bot.on("callback_query:data", ...)` routing perm: and q: prefixes
195
+ - Change handleMessage return type to `{ turn: ActiveTurn }`
196
+ - Start typing loop after handleMessage in Grammy handler
197
+
198
+ ### 7. Modified: index.ts — Wire new event types + PendingRequests
199
+ - Create PendingRequests instance (maxEntries: 200, ttlMs: 10min)
200
+ - Handle `permission.asked` → formatPermissionMessage + sendMessage + store in PendingRequests
201
+ - Handle `question.asked` → formatQuestionMessage + sendMessage + store in PendingRequests
202
+ - Typing indicator started from bot.ts Grammy handler (not index.ts)
203
+
204
+ ## Edge Cases (Phase 2 MVP decisions)
205
+
206
+ - **"Always" auto-resolve**: Server may auto-resolve other permissions. Stale buttons handled
207
+ gracefully ("requestID not found" → "This request has expired.")
208
+ - **Double-click**: PendingRequests.delete() on first click prevents duplicate SDK calls
209
+ - **TTL expiry**: Clicking button after 10min → "This request has expired."
210
+ - **`answerCallbackQuery()` mandatory**: Always call first, prevents Telegram loading spinner
211
+ - **`multiple: true` questions**: Not supported in Phase 2 MVP (single-select only)
212
+ - **`custom: true` questions**: Not supported (no text input in inline keyboards)
213
+ - **Multiple questions per event**: Phase 2 handles first question only (rare in practice)
214
+
215
+ ## Acceptance Criteria
216
+
217
+ - [ ] Permission request shows inline buttons (Allow/Always/Deny)
218
+ - [ ] Clicking Allow continues AI generation
219
+ - [ ] Clicking Deny stops the tool call
220
+ - [ ] Question shows options as inline buttons
221
+ - [ ] Clicking option sends reply to SDK
222
+ - [ ] Clicking Skip rejects the question
223
+ - [ ] `/cancel` aborts running generation
224
+ - [ ] Typing indicator active during turn
225
+ - [ ] Button messages are edited after click (show decision)
226
+ - [ ] Expired/unknown requests handled gracefully
227
+ - [ ] `bun test` passes (all unit tests)
228
+ - [ ] `bun test ./e2e/phase-2.test.ts` passes
229
+ - [ ] All Phase 0-1 E2E tests still pass (regression)
230
+
231
+ ## Estimated Scope
232
+
233
+ - 5 new source files + 5 test files + 1 E2E test file
234
+ - ~400-500 LOC (src) + ~300-400 LOC (tests)
235
+ - Modified: bot.ts, index.ts