@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,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** |