@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
|
@@ -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: `& < >` → `& < >`
|
|
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
|