@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,566 @@
|
|
|
1
|
+
# Phase 4 — Session Management + Hardening
|
|
2
|
+
|
|
3
|
+
**Goal:** Add session management commands, security allowlist, fix the streaming
|
|
4
|
+
interruption bug, restore sessions on restart, and register the command menu in Telegram.
|
|
5
|
+
|
|
6
|
+
## What This Phase Delivers
|
|
7
|
+
|
|
8
|
+
1. **Allowlist middleware (N1)** — `TELEGRAM_ALLOWED_USERS` env var restricts bot access
|
|
9
|
+
to specific Telegram user IDs. Grammy middleware at the top silently ignores non-allowed users.
|
|
10
|
+
|
|
11
|
+
2. **Streaming interruption fix (N2)** — When a user sends a new message while the bot is
|
|
12
|
+
still streaming a response, the old turn's SSE events were corrupting the new turn.
|
|
13
|
+
Fix: abort the old prompt on the server + generation counter to ignore stale events.
|
|
14
|
+
|
|
15
|
+
3. **Session restore on restart (N3)** — On startup, call `sdk.session.list()` and
|
|
16
|
+
pre-populate SessionManager with sessions matching `Telegram {chatId}` title pattern.
|
|
17
|
+
|
|
18
|
+
4. **Telegram command menu (N4)** — Register all commands via `bot.api.setMyCommands()`
|
|
19
|
+
so they appear in Telegram's "/" autocomplete menu.
|
|
20
|
+
|
|
21
|
+
5. **`/list` command** — Show sessions with inline keyboard, click to switch.
|
|
22
|
+
|
|
23
|
+
6. **`/rename <title>` command** — Rename current session.
|
|
24
|
+
|
|
25
|
+
7. **`/delete` command** — Delete current session.
|
|
26
|
+
|
|
27
|
+
8. **`/info` command** — Show session info (title, created, updated, directory).
|
|
28
|
+
|
|
29
|
+
9. **`/history` command** — Show recent messages in current session.
|
|
30
|
+
|
|
31
|
+
10. **`/summarize` command** — Summarize current session.
|
|
32
|
+
|
|
33
|
+
## Architecture
|
|
34
|
+
|
|
35
|
+
### Allowlist Middleware
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Grammy middleware stack:
|
|
39
|
+
1. allowlistMiddleware(config.allowedUsers) ← NEW (top of stack)
|
|
40
|
+
2. bot.command("start", ...)
|
|
41
|
+
3. bot.command("new", ...)
|
|
42
|
+
4. bot.command("list", ...) ← NEW
|
|
43
|
+
5. bot.command("rename", ...) ← NEW
|
|
44
|
+
6. bot.command("delete", ...) ← NEW
|
|
45
|
+
7. bot.command("info", ...) ← NEW
|
|
46
|
+
8. bot.command("history", ...) ← NEW
|
|
47
|
+
9. bot.command("summarize", ...) ← NEW
|
|
48
|
+
10. bot.command("cancel", ...)
|
|
49
|
+
11. bot.on("callback_query:data", ...) ← Extended with sess: prefix
|
|
50
|
+
12. bot.on("message:text", ...) ← Modified (abort old prompt)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Streaming Interruption Fix
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
BEFORE (bug):
|
|
57
|
+
User sends message A
|
|
58
|
+
→ turnManager.start() → abort old turn's draft
|
|
59
|
+
→ sdk.session.prompt(A) fires
|
|
60
|
+
→ SSE events from A start arriving
|
|
61
|
+
User sends message B (while A still streaming)
|
|
62
|
+
→ turnManager.start() → abort turn A's draft
|
|
63
|
+
→ sdk.session.prompt(B) fires
|
|
64
|
+
→ SSE events from A continue arriving ← BUG
|
|
65
|
+
→ session.idle from A finalizes turn B prematurely
|
|
66
|
+
|
|
67
|
+
AFTER (fix):
|
|
68
|
+
User sends message A
|
|
69
|
+
→ turnManager.start() → generation=1
|
|
70
|
+
→ sdk.session.prompt(A) fires
|
|
71
|
+
→ SSE events arrive, check generation=1 ✓
|
|
72
|
+
User sends message B (while A still streaming)
|
|
73
|
+
→ sdk.session.abort(sessionId) ← NEW: stop server-side generation
|
|
74
|
+
→ turnManager.start() → generation=2
|
|
75
|
+
→ sdk.session.prompt(B) fires
|
|
76
|
+
→ Stale events from A arrive, check generation≠2 → IGNORED
|
|
77
|
+
→ Events from B arrive, check generation=2 ✓
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Session Restore Flow
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Bot startup:
|
|
84
|
+
1. initSdk()
|
|
85
|
+
2. Create SessionManager
|
|
86
|
+
3. sessionManager.restore(sdk) ← NEW
|
|
87
|
+
→ sdk.session.list()
|
|
88
|
+
→ Filter: title matches "Telegram {chatId}"
|
|
89
|
+
→ For each match: sessionManager.set(chatId, { sessionId, directory })
|
|
90
|
+
→ Log: "Restored N sessions"
|
|
91
|
+
4. Create bot, EventBus, etc.
|
|
92
|
+
5. bot.api.setMyCommands([...]) ← NEW
|
|
93
|
+
6. bot.start()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Session Commands Flow
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
/list
|
|
100
|
+
→ sdk.session.list()
|
|
101
|
+
→ Filter: not archived, sort by updated desc, take 10
|
|
102
|
+
→ Format as inline keyboard (1 session per row)
|
|
103
|
+
→ callback_data: "sess:{sessionId prefix}" (fit 64 bytes)
|
|
104
|
+
|
|
105
|
+
/list callback (sess: prefix)
|
|
106
|
+
→ sdk.session.list() (re-fetch for full ID)
|
|
107
|
+
→ Find session by prefix match
|
|
108
|
+
→ sessionManager.set(chatKey, { sessionId, directory })
|
|
109
|
+
→ Edit message: "Switched to: {title}"
|
|
110
|
+
|
|
111
|
+
/rename <title>
|
|
112
|
+
→ sessionManager.get(chatKey)
|
|
113
|
+
→ sdk.session.update({ sessionID, title })
|
|
114
|
+
→ Reply: "Session renamed to: {title}"
|
|
115
|
+
|
|
116
|
+
/delete
|
|
117
|
+
→ sessionManager.get(chatKey)
|
|
118
|
+
→ sdk.session.delete({ sessionID })
|
|
119
|
+
→ sessionManager.remove(chatKey)
|
|
120
|
+
→ Reply: "Session deleted."
|
|
121
|
+
|
|
122
|
+
/info
|
|
123
|
+
→ sessionManager.get(chatKey) → sessionId
|
|
124
|
+
→ sdk.session.get({ sessionID })
|
|
125
|
+
→ Format: title, created, updated, directory, message count
|
|
126
|
+
→ Reply with formatted info
|
|
127
|
+
|
|
128
|
+
/history
|
|
129
|
+
→ sessionManager.get(chatKey) → sessionId
|
|
130
|
+
→ sdk.session.messages({ sessionID }) → paginated messages
|
|
131
|
+
→ Format: last 10 messages as "role: text" (truncated to fit)
|
|
132
|
+
→ Reply with formatted history
|
|
133
|
+
|
|
134
|
+
/summarize
|
|
135
|
+
→ sessionManager.get(chatKey) → sessionId
|
|
136
|
+
→ sdk.session.summarize({ sessionID })
|
|
137
|
+
→ Reply with summary text
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Callback Data Design (session switching)
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
Session buttons:
|
|
144
|
+
sess:{sessionId prefix} → 5 + 20 = 25 bytes ✓ (well under 64 bytes)
|
|
145
|
+
|
|
146
|
+
We use first 20 chars of sessionId as prefix.
|
|
147
|
+
On callback, re-fetch session.list() and find by startsWith(prefix).
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Config Changes
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// config.ts — NEW field
|
|
154
|
+
export type Config = {
|
|
155
|
+
botToken: string
|
|
156
|
+
opencodeUrl: string
|
|
157
|
+
projectDirectory: string
|
|
158
|
+
testEnv: boolean
|
|
159
|
+
allowedUsers: number[] // ← NEW
|
|
160
|
+
e2e: { apiId, apiHash, session, botUsername }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Parsing:
|
|
164
|
+
// allowedUsers: (process.env.TELEGRAM_ALLOWED_USERS ?? "")
|
|
165
|
+
// .split(",").map(s => s.trim()).filter(Boolean).map(Number).filter(n => !isNaN(n))
|
|
166
|
+
// Empty string / undefined → [] (accept all)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## ActiveTurn Changes
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
export type ActiveTurn = {
|
|
173
|
+
sessionId: string
|
|
174
|
+
chatId: number
|
|
175
|
+
abortController: AbortController
|
|
176
|
+
accumulatedText: string
|
|
177
|
+
toolSuffix: string
|
|
178
|
+
timers: Set<ReturnType<typeof setTimeout>>
|
|
179
|
+
draft: { stop(): void; getMessageId(): number | null; update(text: string): Promise<void> } | null
|
|
180
|
+
generation: number // ← NEW: monotonic counter for stale event detection
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## New Files
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
src/
|
|
188
|
+
handlers/
|
|
189
|
+
allowlist.ts ← Grammy middleware factory
|
|
190
|
+
allowlist.test.ts ← 6 tests
|
|
191
|
+
sessions.ts ← handleList, handleRename, handleDelete,
|
|
192
|
+
handleInfo, handleHistory, handleSummarize,
|
|
193
|
+
parseSessionCallback, formatSessionList,
|
|
194
|
+
formatSessionInfo, formatHistory
|
|
195
|
+
sessions.test.ts ← 24 tests
|
|
196
|
+
e2e/
|
|
197
|
+
phase-4.test.ts ← 5 E2E tests
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Modified Files
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
src/
|
|
204
|
+
config.ts ← Add allowedUsers field + parsing
|
|
205
|
+
config.test.ts ← 3 new tests for allowedUsers parsing
|
|
206
|
+
turn-manager.ts ← Add generation counter to ActiveTurn
|
|
207
|
+
turn-manager.test.ts ← 3 new tests for generation field
|
|
208
|
+
session-manager.ts ← Add restore() method
|
|
209
|
+
session-manager.test.ts ← 4 new tests for restore()
|
|
210
|
+
bot.ts ← Allowlist middleware, new commands (/list, /rename,
|
|
211
|
+
/delete, /info, /history, /summarize), sess: callback,
|
|
212
|
+
abort old prompt before new turn, pass generation
|
|
213
|
+
bot.test.ts ← Tests for new commands + abort behavior
|
|
214
|
+
index.ts ← Session restore on startup, setMyCommands,
|
|
215
|
+
generation check in onEvent handler
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## TDD Execution Order (bottom-up by dependency)
|
|
219
|
+
|
|
220
|
+
### Group A — Foundational / Independent Pieces
|
|
221
|
+
|
|
222
|
+
#### 1. config.ts — allowedUsers field (3 new tests)
|
|
223
|
+
|
|
224
|
+
Modify existing pure function. Tests:
|
|
225
|
+
|
|
226
|
+
1. `TELEGRAM_ALLOWED_USERS="123,456"` → `allowedUsers: [123, 456]`
|
|
227
|
+
2. `TELEGRAM_ALLOWED_USERS=""` → `allowedUsers: []` (accept all)
|
|
228
|
+
3. `TELEGRAM_ALLOWED_USERS` undefined → `allowedUsers: []` (accept all)
|
|
229
|
+
|
|
230
|
+
#### 2. handlers/allowlist.ts — Allowlist middleware (6 tests)
|
|
231
|
+
|
|
232
|
+
Pure middleware factory, depends only on Config.allowedUsers.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// src/handlers/allowlist.ts
|
|
236
|
+
import type { MiddlewareFn, Context } from "grammy"
|
|
237
|
+
|
|
238
|
+
export function createAllowlistMiddleware(
|
|
239
|
+
allowedUsers: number[],
|
|
240
|
+
): MiddlewareFn<Context> {
|
|
241
|
+
// If empty list → allow all
|
|
242
|
+
if (allowedUsers.length === 0) {
|
|
243
|
+
return (ctx, next) => next()
|
|
244
|
+
}
|
|
245
|
+
const allowed = new Set(allowedUsers)
|
|
246
|
+
return (ctx, next) => {
|
|
247
|
+
const userId = ctx.from?.id
|
|
248
|
+
if (!userId || !allowed.has(userId)) return // silently ignore
|
|
249
|
+
return next()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Tests:
|
|
255
|
+
1. Empty allowedUsers list → calls next() (allows all)
|
|
256
|
+
2. User ID in list → calls next()
|
|
257
|
+
3. User ID NOT in list → does not call next()
|
|
258
|
+
4. No `from` on context → does not call next()
|
|
259
|
+
5. Multiple users in list → all allowed
|
|
260
|
+
6. Non-numeric entries filtered out during config parsing (tested in config.test.ts)
|
|
261
|
+
|
|
262
|
+
#### 3. turn-manager.ts — Generation counter (3 new tests)
|
|
263
|
+
|
|
264
|
+
Add `generation` field to ActiveTurn, increment on each `start()`.
|
|
265
|
+
|
|
266
|
+
Changes:
|
|
267
|
+
- Add class-level `private generationCounter = 0`
|
|
268
|
+
- In `start()`: `this.generationCounter++`, assign to turn
|
|
269
|
+
- ActiveTurn gains `generation: number`
|
|
270
|
+
|
|
271
|
+
Tests:
|
|
272
|
+
1. First turn gets generation=1
|
|
273
|
+
2. Second turn (same session) gets generation=2
|
|
274
|
+
3. Different sessions get independent generations (counter is global, values are unique)
|
|
275
|
+
|
|
276
|
+
#### 4. session-manager.ts — restore() method (4 new tests)
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
async restore(sdk: OpencodeClient): Promise<number> {
|
|
280
|
+
const result = await sdk.session.list()
|
|
281
|
+
const sessions = result.data ?? []
|
|
282
|
+
let restored = 0
|
|
283
|
+
|
|
284
|
+
for (const session of sessions) {
|
|
285
|
+
if (session.time?.archived) continue
|
|
286
|
+
const match = session.title?.match(/^Telegram (\d+)$/)
|
|
287
|
+
if (!match) continue
|
|
288
|
+
|
|
289
|
+
const chatKey = match[1]
|
|
290
|
+
// Only restore if not already mapped
|
|
291
|
+
if (!this.get(chatKey)) {
|
|
292
|
+
this.set(chatKey, {
|
|
293
|
+
sessionId: session.id,
|
|
294
|
+
directory: session.directory ?? "",
|
|
295
|
+
})
|
|
296
|
+
restored++
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return restored
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Tests (with mock SDK):
|
|
305
|
+
1. Restores sessions with title "Telegram 12345" pattern
|
|
306
|
+
2. Ignores archived sessions
|
|
307
|
+
3. Ignores sessions without matching title pattern
|
|
308
|
+
4. Returns count of restored sessions
|
|
309
|
+
5. Does not overwrite existing mappings (if chatKey already present)
|
|
310
|
+
|
|
311
|
+
Wait — that's 5 tests, not 4. Let me recount: the "does not overwrite" case is important, so 5 tests.
|
|
312
|
+
|
|
313
|
+
#### 5. handlers/sessions.ts — Session command handlers (24 tests)
|
|
314
|
+
|
|
315
|
+
Pure functions + thin async handlers, depend on SDK types.
|
|
316
|
+
|
|
317
|
+
**Functions:**
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
// Formatting
|
|
321
|
+
formatSessionList(sessions: Session[]): { text: string; reply_markup: any }
|
|
322
|
+
formatSessionInfo(session: Session): string
|
|
323
|
+
formatHistory(messages: Message[]): string
|
|
324
|
+
parseSessionCallback(data: string): { sessionPrefix: string } | null
|
|
325
|
+
|
|
326
|
+
// Handlers (return string for reply, or { text, reply_markup } for keyboard)
|
|
327
|
+
handleList(params): Promise<{ text: string; reply_markup: any }>
|
|
328
|
+
handleRename(params): Promise<string>
|
|
329
|
+
handleDelete(params): Promise<string>
|
|
330
|
+
handleInfo(params): Promise<string>
|
|
331
|
+
handleHistory(params): Promise<string>
|
|
332
|
+
handleSummarize(params): Promise<string>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**Tests:**
|
|
336
|
+
|
|
337
|
+
*formatSessionList:*
|
|
338
|
+
1. Formats sessions as inline keyboard rows (title + date)
|
|
339
|
+
2. Filters out archived sessions
|
|
340
|
+
3. Sorts by updated desc
|
|
341
|
+
4. Limits to 10 sessions
|
|
342
|
+
5. Empty list returns "No sessions found." text
|
|
343
|
+
|
|
344
|
+
*parseSessionCallback:*
|
|
345
|
+
6. `parseSessionCallback("sess:abc123")` → `{ sessionPrefix: "abc123" }`
|
|
346
|
+
7. `parseSessionCallback("invalid")` → null
|
|
347
|
+
|
|
348
|
+
*handleList:*
|
|
349
|
+
8. Returns formatted session list from SDK
|
|
350
|
+
9. Returns "No sessions found." when list is empty
|
|
351
|
+
|
|
352
|
+
*handleRename:*
|
|
353
|
+
10. Returns "No active session." when no session mapped
|
|
354
|
+
11. Returns "Session renamed to: {title}" on success
|
|
355
|
+
12. Calls `sdk.session.update()` with correct params
|
|
356
|
+
13. Returns "Usage: /rename <title>" when title is empty
|
|
357
|
+
|
|
358
|
+
*handleDelete:*
|
|
359
|
+
14. Returns "No active session." when no session mapped
|
|
360
|
+
15. Returns "Session deleted." on success
|
|
361
|
+
16. Calls `sdk.session.delete()` then `sessionManager.remove()`
|
|
362
|
+
|
|
363
|
+
*handleInfo:*
|
|
364
|
+
17. Returns "No active session." when no session mapped
|
|
365
|
+
18. Returns formatted session info on success
|
|
366
|
+
19. Includes title, created date, updated date
|
|
367
|
+
|
|
368
|
+
*handleHistory:*
|
|
369
|
+
20. Returns "No active session." when no session mapped
|
|
370
|
+
21. Returns formatted message history
|
|
371
|
+
22. Truncates long messages
|
|
372
|
+
23. Returns "No messages yet." when history is empty
|
|
373
|
+
|
|
374
|
+
*handleSummarize:*
|
|
375
|
+
24. Returns "No active session." when no session mapped
|
|
376
|
+
|
|
377
|
+
### Group B — Wiring (bot.ts + index.ts)
|
|
378
|
+
|
|
379
|
+
#### 6. bot.ts — New commands, allowlist, abort (12 new tests)
|
|
380
|
+
|
|
381
|
+
Changes:
|
|
382
|
+
- Add `allowedUsers` to BotDeps (or accept from Config)
|
|
383
|
+
- Insert `createAllowlistMiddleware()` as first middleware
|
|
384
|
+
- Add commands: `/list`, `/rename`, `/delete`, `/info`, `/history`, `/summarize`
|
|
385
|
+
- Add `sess:` prefix handling in callback_query handler
|
|
386
|
+
- In `handleMessage()`: call `sdk.session.abort()` before `turnManager.start()` if existing turn
|
|
387
|
+
- Return `generation` on turn for index.ts to track
|
|
388
|
+
|
|
389
|
+
New tests:
|
|
390
|
+
1. Allowlist middleware blocks unauthorized user
|
|
391
|
+
2. Allowlist middleware allows authorized user
|
|
392
|
+
3. `/list` calls handleList and replies with keyboard
|
|
393
|
+
4. `/rename test` calls handleRename with title "test"
|
|
394
|
+
5. `/rename` without args returns usage message
|
|
395
|
+
6. `/delete` calls handleDelete
|
|
396
|
+
7. `/info` calls handleInfo
|
|
397
|
+
8. `/history` calls handleHistory
|
|
398
|
+
9. `/summarize` calls handleSummarize
|
|
399
|
+
10. `sess:` callback switches session
|
|
400
|
+
11. `handleMessage` calls `sdk.session.abort()` when existing turn present
|
|
401
|
+
12. `handleMessage` does NOT call abort when no existing turn
|
|
402
|
+
|
|
403
|
+
#### 7. index.ts — Session restore, command menu, generation check
|
|
404
|
+
|
|
405
|
+
Changes:
|
|
406
|
+
- Call `sessionManager.restore(sdk)` after creating managers
|
|
407
|
+
- Call `bot.api.setMyCommands([...])` before `bot.start()`
|
|
408
|
+
- In `onEvent` handler: streaming interruption fix (N2)
|
|
409
|
+
|
|
410
|
+
**Streaming interruption defense layers:**
|
|
411
|
+
|
|
412
|
+
1. **Primary: `sdk.session.abort()`** — In `handleMessage`, before starting a new turn,
|
|
413
|
+
abort the old server-side prompt. The server stops emitting SSE events for the aborted prompt.
|
|
414
|
+
|
|
415
|
+
2. **Secondary: `turnManager.start()` replaces old turn** — The old turn's draft is stopped,
|
|
416
|
+
AbortController fires, timers cleared. The new turn takes over the sessionId slot.
|
|
417
|
+
|
|
418
|
+
3. **Safety net: `finalizeResponse` empty-text guard** — If a stale `session.idle` somehow
|
|
419
|
+
arrives and finds the new turn with empty `accumulatedText`, `finalizeResponse` returns
|
|
420
|
+
early (no-op). However, `turnManager.end()` would still end the new turn prematurely.
|
|
421
|
+
|
|
422
|
+
4. **Belt-and-suspenders: generation counter** — Each turn gets a monotonic `generation` number.
|
|
423
|
+
The `session.idle` handler only finalizes if `turn.accumulatedText` is non-empty.
|
|
424
|
+
With abort working correctly, layers 3-4 should never activate.
|
|
425
|
+
|
|
426
|
+
**Decision:** `sdk.session.abort()` is the primary fix. Generation counter is metadata for
|
|
427
|
+
debugging/logging. The empty-text guard in `finalizeResponse` catches the residual edge case.
|
|
428
|
+
|
|
429
|
+
No unit test for index.ts — validated by E2E.
|
|
430
|
+
|
|
431
|
+
Command menu registration:
|
|
432
|
+
```ts
|
|
433
|
+
await bot.api.setMyCommands([
|
|
434
|
+
{ command: "start", description: "Welcome message" },
|
|
435
|
+
{ command: "new", description: "New session" },
|
|
436
|
+
{ command: "cancel", description: "Stop generation" },
|
|
437
|
+
{ command: "list", description: "List sessions" },
|
|
438
|
+
{ command: "rename", description: "Rename session" },
|
|
439
|
+
{ command: "delete", description: "Delete session" },
|
|
440
|
+
{ command: "info", description: "Session info" },
|
|
441
|
+
{ command: "history", description: "Recent messages" },
|
|
442
|
+
{ command: "summarize", description: "Summarize session" },
|
|
443
|
+
])
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Group C — E2E Tests
|
|
447
|
+
|
|
448
|
+
#### 8. E2E: phase-4.test.ts (5 tests)
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
describe("Phase 4 — Session Management + Hardening", () => {
|
|
452
|
+
test("/list shows sessions", async () => {
|
|
453
|
+
// Send a message first to create a session
|
|
454
|
+
await sendAndWait(client, BOT, "Say hello", 60000)
|
|
455
|
+
// Then list
|
|
456
|
+
const reply = await sendAndWait(client, BOT, "/list", 15000)
|
|
457
|
+
// Should contain session info or "Select a session" or inline keyboard
|
|
458
|
+
expect(reply.length).toBeGreaterThan(0)
|
|
459
|
+
}, 90000)
|
|
460
|
+
|
|
461
|
+
test("/info shows session details", async () => {
|
|
462
|
+
const reply = await sendAndWait(client, BOT, "/info", 15000)
|
|
463
|
+
// Should contain session info (title, dates)
|
|
464
|
+
assertContains(reply, /session|title|created/i)
|
|
465
|
+
}, 30000)
|
|
466
|
+
|
|
467
|
+
test("/rename changes session title", async () => {
|
|
468
|
+
const reply = await sendAndWait(client, BOT, "/rename Test Session", 15000)
|
|
469
|
+
assertContains(reply, /renamed/i)
|
|
470
|
+
}, 30000)
|
|
471
|
+
|
|
472
|
+
test("regression: text message gets AI response", async () => {
|
|
473
|
+
const reply = await sendAndWait(
|
|
474
|
+
client, BOT,
|
|
475
|
+
"Say exactly the word hello and nothing else",
|
|
476
|
+
60000,
|
|
477
|
+
)
|
|
478
|
+
assertContains(reply, /hello/i)
|
|
479
|
+
}, 90000)
|
|
480
|
+
|
|
481
|
+
test("regression: /new creates fresh session", async () => {
|
|
482
|
+
const reply = await sendAndWait(client, BOT, "/new", 15000)
|
|
483
|
+
assertContains(reply, /session/i)
|
|
484
|
+
}, 30000)
|
|
485
|
+
})
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Note:** `/delete` E2E test intentionally omitted — it would break subsequent tests by
|
|
489
|
+
removing the active session. `/history` and `/summarize` are hard to E2E test reliably
|
|
490
|
+
(depend on session content and server-side AI). They are covered by unit tests.
|
|
491
|
+
|
|
492
|
+
## Edge Cases
|
|
493
|
+
|
|
494
|
+
### Allowlist
|
|
495
|
+
- Empty `TELEGRAM_ALLOWED_USERS` → accept all (dev/testing mode)
|
|
496
|
+
- User sends message from group chat → `ctx.from.id` is the user, not the group
|
|
497
|
+
- Bot added to group → only allowed user IDs can interact
|
|
498
|
+
- Callback queries also filtered (middleware runs before all handlers)
|
|
499
|
+
|
|
500
|
+
### Streaming Interruption
|
|
501
|
+
- User sends 3 messages rapidly → only last one's turn survives
|
|
502
|
+
- `sdk.session.abort()` fails → turn still starts (log error, continue)
|
|
503
|
+
- Stale `session.idle` with empty `accumulatedText` → `finalizeResponse` returns early (no-op)
|
|
504
|
+
|
|
505
|
+
### Session Restore
|
|
506
|
+
- No sessions on server → restore returns 0, bot starts fresh
|
|
507
|
+
- Session titles don't match pattern → ignored (only "Telegram {chatId}" restored)
|
|
508
|
+
- Multiple sessions for same chatId → last one wins (sorted by server)
|
|
509
|
+
- Server unreachable during restore → log error, continue without restore
|
|
510
|
+
- **Known limitation:** Sessions renamed via `/rename` won't auto-restore (title no longer matches pattern). User can still switch manually via `/list`.
|
|
511
|
+
|
|
512
|
+
### Session Commands
|
|
513
|
+
- `/list` with no sessions → "No sessions found."
|
|
514
|
+
- `/list` with >10 sessions → only shows 10 most recent
|
|
515
|
+
- `/rename` without argument → "Usage: /rename <title>"
|
|
516
|
+
- `/delete` with no session → "No active session."
|
|
517
|
+
- `/info` with no session → "No active session."
|
|
518
|
+
- `/history` with no messages → "No messages yet."
|
|
519
|
+
- `/summarize` with no session → "No active session."
|
|
520
|
+
- `sess:` callback with deleted session → "Session not found."
|
|
521
|
+
- Session ID prefix collision (unlikely with 20 chars) → first match wins
|
|
522
|
+
|
|
523
|
+
### Command Menu
|
|
524
|
+
- `setMyCommands` failure → log error, bot still starts
|
|
525
|
+
- Commands registered globally (not per-chat)
|
|
526
|
+
|
|
527
|
+
## Acceptance Criteria
|
|
528
|
+
|
|
529
|
+
- [ ] `TELEGRAM_ALLOWED_USERS` env var restricts bot access
|
|
530
|
+
- [ ] Empty/undefined allowlist accepts all users
|
|
531
|
+
- [ ] Non-allowed users silently ignored (no error message)
|
|
532
|
+
- [ ] Sending new message while streaming aborts old generation
|
|
533
|
+
- [ ] Stale SSE events from aborted prompt don't corrupt new turn
|
|
534
|
+
- [ ] Bot restores sessions on restart (matching "Telegram {chatId}" pattern)
|
|
535
|
+
- [ ] Commands appear in Telegram "/" menu
|
|
536
|
+
- [ ] `/list` shows sessions with inline keyboard
|
|
537
|
+
- [ ] Clicking session button switches active session
|
|
538
|
+
- [ ] `/rename <title>` changes session title
|
|
539
|
+
- [ ] `/delete` removes session
|
|
540
|
+
- [ ] `/info` shows session details
|
|
541
|
+
- [ ] `/history` shows recent messages
|
|
542
|
+
- [ ] `/summarize` produces session summary
|
|
543
|
+
- [ ] `bun test src/` passes (all unit tests, ~180+)
|
|
544
|
+
- [ ] `bun test ./e2e/phase-4.test.ts` passes
|
|
545
|
+
- [ ] All Phase 0-3 E2E tests still pass (regression)
|
|
546
|
+
|
|
547
|
+
## Estimated Scope
|
|
548
|
+
|
|
549
|
+
- 2 new source files + 2 test files + 1 E2E test file
|
|
550
|
+
- ~350-450 LOC (src) + ~400-500 LOC (tests)
|
|
551
|
+
- Modified: config.ts, config.test.ts, turn-manager.ts, turn-manager.test.ts,
|
|
552
|
+
session-manager.ts, session-manager.test.ts, bot.ts, bot.test.ts, index.ts
|
|
553
|
+
|
|
554
|
+
### Test Count Estimate
|
|
555
|
+
|
|
556
|
+
| File | New Tests |
|
|
557
|
+
|------|-----------|
|
|
558
|
+
| config.test.ts | 3 |
|
|
559
|
+
| handlers/allowlist.test.ts | 6 |
|
|
560
|
+
| turn-manager.test.ts | 3 |
|
|
561
|
+
| session-manager.test.ts | 5 |
|
|
562
|
+
| handlers/sessions.test.ts | 24 |
|
|
563
|
+
| bot.test.ts | 12 |
|
|
564
|
+
| **Unit total** | **~53 new → ~190 total** |
|
|
565
|
+
| E2E phase-4.test.ts | 5 |
|
|
566
|
+
| **E2E total** | **5 new → ~17 total** |
|