@pedrohnas/opencode-telegram 1.2.1 → 1.3.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/.claude/skills/playwright-cli/data/page-2026-02-09T01-41-55-194Z.yml +36 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-42-17-115Z.yml +36 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-15-988Z.yml +26 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-26-107Z.yml +26 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-03-139Z.yml +29 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-21-579Z.yml +29 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-48-051Z.yml +30 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-27-632Z.yml +33 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-46-519Z.yml +33 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-28-491Z.yml +349 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-34-834Z.yml +349 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-54-066Z.yml +168 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-48-19-667Z.yml +219 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-32-311Z.yml +221 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-57-109Z.yml +230 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-24-052Z.yml +235 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-41-148Z.yml +248 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-10-916Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-28-271Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-32-324Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-47-801Z.yml +196 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-07-361Z.yml +203 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-35-534Z.yml +49 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-57-04-658Z.yml +52 -0
- package/docs/AUDIT.md +193 -0
- package/docs/PROGRESS.md +188 -0
- package/docs/plans/phase-5.md +410 -0
- package/docs/plans/phase-6.5.md +426 -0
- package/docs/plans/phase-6.md +349 -0
- package/e2e/helpers.ts +34 -0
- package/e2e/phase-5.test.ts +295 -0
- package/e2e/phase-6.5.test.ts +239 -0
- package/e2e/phase-6.test.ts +302 -0
- package/package.json +5 -2
- package/src/api-server.test.ts +309 -0
- package/src/api-server.ts +201 -0
- package/src/bot.test.ts +354 -0
- package/src/bot.ts +200 -2
- package/src/config.test.ts +16 -0
- package/src/config.ts +4 -0
- package/src/event-bus.test.ts +337 -1
- package/src/event-bus.ts +83 -3
- package/src/handlers/agents.test.ts +122 -0
- package/src/handlers/agents.ts +93 -0
- package/src/handlers/media.test.ts +264 -0
- package/src/handlers/media.ts +168 -0
- package/src/handlers/models.test.ts +319 -0
- package/src/handlers/models.ts +191 -0
- package/src/index.ts +15 -0
- package/src/send/draft-stream.test.ts +76 -0
- package/src/send/draft-stream.ts +13 -1
- package/src/session-manager.test.ts +46 -0
- package/src/session-manager.ts +10 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Phase 6 — Media & Files
|
|
2
|
+
|
|
3
|
+
**Goal:** Accept photos, documents, audio, voice messages, and video from Telegram
|
|
4
|
+
users, download them, convert to data URLs, and send as `FilePartInput` alongside
|
|
5
|
+
text to the OpenCode SDK. This allows the AI to process images, read documents,
|
|
6
|
+
and receive audio/video files.
|
|
7
|
+
|
|
8
|
+
## What This Phase Delivers
|
|
9
|
+
|
|
10
|
+
1. **Photo handling** — Photos sent to the bot are downloaded (highest resolution),
|
|
11
|
+
converted to a base64 data URL, and sent as a `FilePartInput` part to the SDK.
|
|
12
|
+
|
|
13
|
+
2. **Document handling** — Documents (PDF, code files, etc.) are downloaded and
|
|
14
|
+
sent the same way. The original filename is preserved.
|
|
15
|
+
|
|
16
|
+
3. **Voice/Audio handling** — Voice messages (OGG) and audio files are downloaded
|
|
17
|
+
and sent as file parts.
|
|
18
|
+
|
|
19
|
+
4. **Video handling** — Video files are downloaded and sent as file parts.
|
|
20
|
+
|
|
21
|
+
5. **Caption support** — If a media message has a caption, it becomes the text part.
|
|
22
|
+
If no caption, only the file part is sent.
|
|
23
|
+
|
|
24
|
+
6. **Media groups** — When a user sends multiple photos at once, each is processed
|
|
25
|
+
individually (Grammy delivers them as separate updates).
|
|
26
|
+
|
|
27
|
+
7. **File size limit** — Telegram Bot API limits file downloads to 20MB. Files
|
|
28
|
+
exceeding this are rejected with a user-friendly message.
|
|
29
|
+
|
|
30
|
+
## SDK Integration
|
|
31
|
+
|
|
32
|
+
The SDK `session.prompt()` accepts mixed part arrays:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
sdk.session.prompt({
|
|
36
|
+
sessionID: string,
|
|
37
|
+
parts: [
|
|
38
|
+
{ type: "text", text: "What's in this image?" },
|
|
39
|
+
{ type: "file", mime: "image/jpeg", url: "data:image/jpeg;base64,...", filename: "photo.jpg" },
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`FilePartInput` type (from SDK v2):
|
|
45
|
+
```ts
|
|
46
|
+
type FilePartInput = {
|
|
47
|
+
id?: string
|
|
48
|
+
type: "file"
|
|
49
|
+
mime: string
|
|
50
|
+
filename?: string
|
|
51
|
+
url: string // data URL: "data:{mime};base64,{base64data}"
|
|
52
|
+
source?: FilePartSource
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Architecture
|
|
57
|
+
|
|
58
|
+
### Download Flow
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
User sends photo/document/voice/audio/video
|
|
62
|
+
→ Grammy filter: bot.on("message:photo") / bot.on("message:document") / etc.
|
|
63
|
+
→ downloadTelegramFile(fileId, token)
|
|
64
|
+
→ ctx.api.getFile(fileId) → file_path
|
|
65
|
+
→ fetch("https://api.telegram.org/file/bot{token}/{file_path}")
|
|
66
|
+
→ Buffer.from(arrayBuffer)
|
|
67
|
+
→ return { buffer, mime, filename }
|
|
68
|
+
→ bufferToDataUrl(buffer, mime)
|
|
69
|
+
→ "data:{mime};base64,{base64}"
|
|
70
|
+
→ Build parts array: [FilePartInput, TextPartInput?]
|
|
71
|
+
→ handleMessage({ chatId, text, parts, sdk, ... })
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Grammy Handlers
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
bot.on("message:photo", handler) ← Photo (pick highest res from photo array)
|
|
78
|
+
bot.on("message:document", handler) ← Document (any file type)
|
|
79
|
+
bot.on("message:voice", handler) ← Voice message (OGG)
|
|
80
|
+
bot.on("message:audio", handler) ← Audio file
|
|
81
|
+
bot.on("message:video", handler) ← Video file
|
|
82
|
+
bot.on("message:text", handler) ← Text-only (existing — unchanged)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
All media handlers share the same logic pattern, consolidated into a single
|
|
86
|
+
`handleMediaMessage` function.
|
|
87
|
+
|
|
88
|
+
### handleMessage Changes
|
|
89
|
+
|
|
90
|
+
Currently `handleMessage` accepts `text: string` and builds
|
|
91
|
+
`parts: [{ type: "text", text }]`. This needs to be generalized to accept
|
|
92
|
+
an optional pre-built `parts` array.
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
BEFORE:
|
|
96
|
+
handleMessage({ chatId, text, sdk, ... })
|
|
97
|
+
→ parts = [{ type: "text", text }]
|
|
98
|
+
→ sdk.session.prompt({ parts, ... })
|
|
99
|
+
|
|
100
|
+
AFTER:
|
|
101
|
+
handleMessage({ chatId, text, parts?, sdk, ... })
|
|
102
|
+
→ if parts provided: use them directly
|
|
103
|
+
→ else: parts = [{ type: "text", text }]
|
|
104
|
+
→ sdk.session.prompt({ parts, ... })
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## New Files
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
src/
|
|
111
|
+
handlers/
|
|
112
|
+
media.ts ← downloadTelegramFile, bufferToDataUrl,
|
|
113
|
+
buildMediaParts, handleMediaMessage
|
|
114
|
+
media.test.ts ← ~18 tests
|
|
115
|
+
e2e/
|
|
116
|
+
phase-6.test.ts ← 3-4 E2E tests
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Modified Files
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
src/
|
|
123
|
+
bot.ts ← Add media Grammy handlers (photo, document,
|
|
124
|
+
voice, audio, video), generalize handleMessage
|
|
125
|
+
to accept optional parts array
|
|
126
|
+
bot.test.ts ← ~4 new tests for parts passthrough
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## TDD Execution Order (bottom-up by dependency)
|
|
130
|
+
|
|
131
|
+
### Group A — Pure Functions (Independent)
|
|
132
|
+
|
|
133
|
+
#### A1. handlers/media.ts — File download & conversion (18 tests)
|
|
134
|
+
|
|
135
|
+
**Pure functions:**
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
bufferToDataUrl(buffer: Buffer, mime: string): string
|
|
139
|
+
// → "data:{mime};base64,{base64data}"
|
|
140
|
+
|
|
141
|
+
buildFilePart(params: { buffer: Buffer; mime: string; filename: string }): FilePartInput
|
|
142
|
+
// → { type: "file", mime, url: dataUrl, filename }
|
|
143
|
+
|
|
144
|
+
buildMediaParts(params: {
|
|
145
|
+
buffer: Buffer
|
|
146
|
+
mime: string
|
|
147
|
+
filename: string
|
|
148
|
+
caption?: string
|
|
149
|
+
}): Array<TextPartInput | FilePartInput>
|
|
150
|
+
// → [FilePartInput] or [TextPartInput, FilePartInput] if caption exists
|
|
151
|
+
|
|
152
|
+
getMimeFromFileName(filename: string): string
|
|
153
|
+
// → Fallback MIME type from extension when Telegram doesn't provide one
|
|
154
|
+
|
|
155
|
+
extractFileRef(msg: TelegramMessage): { fileId: string; filename?: string; mime?: string } | null
|
|
156
|
+
// → Extract file_id and metadata from photo/document/voice/audio/video
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Async functions:**
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
downloadTelegramFile(params: {
|
|
163
|
+
fileId: string
|
|
164
|
+
token: string
|
|
165
|
+
getFile: (fileId: string) => Promise<{ file_path?: string; file_size?: number }>
|
|
166
|
+
}): Promise<{ buffer: Buffer; mime: string; filename: string } | null>
|
|
167
|
+
// → Downloads file from Telegram API, returns buffer + metadata
|
|
168
|
+
// → Returns null if file_path missing or download fails
|
|
169
|
+
// → Checks file_size against 20MB limit
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Tests:**
|
|
173
|
+
|
|
174
|
+
*bufferToDataUrl:*
|
|
175
|
+
1. Converts buffer to correct data URL format
|
|
176
|
+
2. Handles empty buffer
|
|
177
|
+
3. Handles different MIME types (image/jpeg, application/pdf, audio/ogg)
|
|
178
|
+
|
|
179
|
+
*buildFilePart:*
|
|
180
|
+
4. Creates FilePartInput with correct fields
|
|
181
|
+
5. URL is a valid data URL
|
|
182
|
+
|
|
183
|
+
*buildMediaParts:*
|
|
184
|
+
6. Without caption → returns [FilePartInput] only
|
|
185
|
+
7. With caption → returns [TextPartInput, FilePartInput]
|
|
186
|
+
8. Empty caption treated as no caption
|
|
187
|
+
|
|
188
|
+
*getMimeFromFileName:*
|
|
189
|
+
9. `.jpg` → `image/jpeg`
|
|
190
|
+
10. `.pdf` → `application/pdf`
|
|
191
|
+
11. `.py` → `text/x-python`
|
|
192
|
+
12. Unknown extension → `application/octet-stream`
|
|
193
|
+
|
|
194
|
+
*extractFileRef:*
|
|
195
|
+
13. Photo message → picks last (highest res) photo, returns file_id
|
|
196
|
+
14. Document message → returns file_id + file_name + mime_type
|
|
197
|
+
15. Voice message → returns file_id with audio/ogg mime
|
|
198
|
+
16. Audio message → returns file_id + mime_type
|
|
199
|
+
17. Video message → returns file_id with video/mp4 mime
|
|
200
|
+
18. Message with no media → returns null
|
|
201
|
+
|
|
202
|
+
*downloadTelegramFile:*
|
|
203
|
+
(Tested with mocked getFile and fetch)
|
|
204
|
+
19. Downloads and returns buffer + mime + filename
|
|
205
|
+
20. Returns null when file_path is missing
|
|
206
|
+
21. Returns null when file_size > 20MB
|
|
207
|
+
|
|
208
|
+
### Group B — Wiring (bot.ts)
|
|
209
|
+
|
|
210
|
+
#### B2. bot.ts — Media handlers + handleMessage generalization (4 tests)
|
|
211
|
+
|
|
212
|
+
Changes:
|
|
213
|
+
- **Generalize `handleMessage`** to accept optional `parts` array parameter
|
|
214
|
+
- Add `bot.on("message:photo", ...)` handler
|
|
215
|
+
- Add `bot.on("message:document", ...)` handler
|
|
216
|
+
- Add `bot.on("message:voice", ...)` handler
|
|
217
|
+
- Add `bot.on("message:audio", ...)` handler
|
|
218
|
+
- Add `bot.on("message:video", ...)` handler
|
|
219
|
+
- All media handlers share common logic:
|
|
220
|
+
1. Extract file ref from message
|
|
221
|
+
2. Download file
|
|
222
|
+
3. Build parts (file + optional caption)
|
|
223
|
+
4. Call handleMessage with parts
|
|
224
|
+
|
|
225
|
+
**New tests:**
|
|
226
|
+
1. `handleMessage` with explicit parts array uses them directly (not text)
|
|
227
|
+
2. `handleMessage` without parts builds text part from text param (backward compat)
|
|
228
|
+
3. `handleMessage` with both text and parts — parts wins
|
|
229
|
+
4. `handleMessage` with empty parts array still works
|
|
230
|
+
|
|
231
|
+
### Group C — E2E Tests
|
|
232
|
+
|
|
233
|
+
#### C3. E2E: phase-6.test.ts (4 tests)
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
describe("Phase 6 — Media & Files", () => {
|
|
237
|
+
test("sending photo gets AI response about the image", async () => {
|
|
238
|
+
// Send a photo to the bot
|
|
239
|
+
// Assert bot responds (text length > 0)
|
|
240
|
+
}, 120000)
|
|
241
|
+
|
|
242
|
+
test("sending document gets AI response", async () => {
|
|
243
|
+
// Send a small text file as document
|
|
244
|
+
// Assert bot responds
|
|
245
|
+
}, 120000)
|
|
246
|
+
|
|
247
|
+
test("photo with caption includes caption in response context", async () => {
|
|
248
|
+
// Send photo with caption "What is this?"
|
|
249
|
+
// Assert bot responds
|
|
250
|
+
}, 120000)
|
|
251
|
+
|
|
252
|
+
test("regression: text message still works after media", async () => {
|
|
253
|
+
// Send /new, then text message
|
|
254
|
+
// Assert bot responds
|
|
255
|
+
}, 120000)
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Grammy Middleware Stack (updated)
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
1. allowlistMiddleware(config.allowedUsers)
|
|
263
|
+
2. bot.command("start", ...)
|
|
264
|
+
3. bot.command("new", ...)
|
|
265
|
+
4. bot.command("list", ...)
|
|
266
|
+
5. bot.command("rename", ...)
|
|
267
|
+
6. bot.command("delete", ...)
|
|
268
|
+
7. bot.command("info", ...)
|
|
269
|
+
8. bot.command("history", ...)
|
|
270
|
+
9. bot.command("summarize", ...)
|
|
271
|
+
10. bot.command("model", ...)
|
|
272
|
+
11. bot.command("agent", ...)
|
|
273
|
+
12. bot.command("cancel", ...)
|
|
274
|
+
13. bot.on("callback_query:data", ...)
|
|
275
|
+
14. bot.on("message:photo", ...) ← NEW
|
|
276
|
+
15. bot.on("message:document", ...) ← NEW
|
|
277
|
+
16. bot.on("message:voice", ...) ← NEW
|
|
278
|
+
17. bot.on("message:audio", ...) ← NEW
|
|
279
|
+
18. bot.on("message:video", ...) ← NEW
|
|
280
|
+
19. bot.on("message:text", ...) ← Existing (text-only)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**Important:** Media handlers must come BEFORE `message:text` because a photo
|
|
284
|
+
with a caption also matches `message:text`. Grammy processes handlers in order
|
|
285
|
+
and stops at the first match.
|
|
286
|
+
|
|
287
|
+
## Edge Cases
|
|
288
|
+
|
|
289
|
+
### File Download
|
|
290
|
+
- `getFile` returns no `file_path` → return null, reply "Could not download file"
|
|
291
|
+
- File > 20MB → reject with "File too large (max 20MB)"
|
|
292
|
+
- Network error during fetch → catch, reply with error message
|
|
293
|
+
- Telegram servers slow → fetch has no built-in timeout, but Grammy handles update timeout
|
|
294
|
+
|
|
295
|
+
### MIME Types
|
|
296
|
+
- Photos always have MIME from Telegram (`image/jpeg` usually)
|
|
297
|
+
- Documents may have `mime_type` field or not → fallback to extension-based detection
|
|
298
|
+
- Voice messages are always `audio/ogg`
|
|
299
|
+
- Videos are usually `video/mp4`
|
|
300
|
+
|
|
301
|
+
### Media Groups
|
|
302
|
+
- Grammy delivers each photo in a media group as a separate update
|
|
303
|
+
- Each triggers the photo handler independently → each gets its own prompt call
|
|
304
|
+
- This is correct behavior: each photo is processed in the context of the session
|
|
305
|
+
|
|
306
|
+
### Caption Handling
|
|
307
|
+
- Photo with caption → caption becomes TextPartInput, photo becomes FilePartInput
|
|
308
|
+
- Photo without caption → only FilePartInput (AI infers what to do)
|
|
309
|
+
- Document with caption → same as photo
|
|
310
|
+
|
|
311
|
+
### Data URL Size
|
|
312
|
+
- A 20MB file → ~27MB base64 string → sent as JSON body to OpenCode server
|
|
313
|
+
- This is within typical HTTP body limits
|
|
314
|
+
- The OpenCode server handles forwarding to AI provider
|
|
315
|
+
|
|
316
|
+
### Sticker/Animation/Video Note
|
|
317
|
+
- Out of scope for Phase 6 — these are special Telegram types
|
|
318
|
+
- Can be added later if needed
|
|
319
|
+
|
|
320
|
+
## Acceptance Criteria
|
|
321
|
+
|
|
322
|
+
- [ ] Sending photo → bot downloads and processes image, responds with AI analysis
|
|
323
|
+
- [ ] Sending document → bot processes file content and responds
|
|
324
|
+
- [ ] Sending voice message → bot receives audio file
|
|
325
|
+
- [ ] Sending audio → bot receives audio file
|
|
326
|
+
- [ ] Sending video → bot receives video file
|
|
327
|
+
- [ ] Caption included as text part alongside file part
|
|
328
|
+
- [ ] Media groups handled (each photo processed independently)
|
|
329
|
+
- [ ] File > 20MB rejected with friendly message
|
|
330
|
+
- [ ] Text-only messages still work (backward compatible)
|
|
331
|
+
- [ ] `bun test src/` passes (all unit tests)
|
|
332
|
+
- [ ] `bun test ./e2e/phase-6.test.ts` passes
|
|
333
|
+
- [ ] All Phase 0-5 E2E tests still pass (regression)
|
|
334
|
+
|
|
335
|
+
## Estimated Scope
|
|
336
|
+
|
|
337
|
+
- 1 new source file + 1 test file + 1 E2E test file
|
|
338
|
+
- ~150-200 LOC (src) + ~250-300 LOC (tests)
|
|
339
|
+
- Modified: bot.ts, bot.test.ts
|
|
340
|
+
|
|
341
|
+
### Test Count Estimate
|
|
342
|
+
|
|
343
|
+
| File | New Tests |
|
|
344
|
+
|------|-----------|
|
|
345
|
+
| handlers/media.test.ts | 21 |
|
|
346
|
+
| bot.test.ts | 4 |
|
|
347
|
+
| **Unit total** | **~25 new → ~247 total** |
|
|
348
|
+
| E2E phase-6.test.ts | 4 |
|
|
349
|
+
| **E2E total** | **4 new → ~24 total** |
|
package/e2e/helpers.ts
CHANGED
|
@@ -106,6 +106,40 @@ export function assertContains(msg: Api.Message, pattern: string | RegExp): void
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Click an inline button, then wait for the message to be edited.
|
|
111
|
+
* Returns the updated message after edit.
|
|
112
|
+
*/
|
|
113
|
+
export async function clickAndWaitEdit(
|
|
114
|
+
client: TelegramClient,
|
|
115
|
+
botUsername: string,
|
|
116
|
+
msgId: number,
|
|
117
|
+
buttonText: string,
|
|
118
|
+
timeoutMs = 10000,
|
|
119
|
+
): Promise<Api.Message> {
|
|
120
|
+
// Capture the message text before clicking
|
|
121
|
+
const before = await client.getMessages(botUsername, { ids: [msgId] })
|
|
122
|
+
const textBefore = before[0]?.text ?? before[0]?.message ?? ""
|
|
123
|
+
|
|
124
|
+
await clickInlineButton(client, botUsername, msgId, buttonText)
|
|
125
|
+
|
|
126
|
+
// Poll until the message text changes (bot edited it)
|
|
127
|
+
const deadline = Date.now() + timeoutMs
|
|
128
|
+
while (Date.now() < deadline) {
|
|
129
|
+
await sleep(1500)
|
|
130
|
+
const messages = await client.getMessages(botUsername, { ids: [msgId] })
|
|
131
|
+
const msg = messages[0]
|
|
132
|
+
const textNow = msg?.text ?? msg?.message ?? ""
|
|
133
|
+
if (textNow !== textBefore) {
|
|
134
|
+
return msg!
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Return whatever the message is — it may have been edited to same-looking text
|
|
139
|
+
const final = await client.getMessages(botUsername, { ids: [msgId] })
|
|
140
|
+
return final[0]!
|
|
141
|
+
}
|
|
142
|
+
|
|
109
143
|
/**
|
|
110
144
|
* Assert that a message has inline keyboard buttons.
|
|
111
145
|
*/
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
|
2
|
+
import { setup, teardown, getClient, getBotUsername } from "./runner"
|
|
3
|
+
import {
|
|
4
|
+
sendAndWait,
|
|
5
|
+
assertHasButtons,
|
|
6
|
+
assertContains,
|
|
7
|
+
clickInlineButton,
|
|
8
|
+
clickAndWaitEdit,
|
|
9
|
+
} from "./helpers"
|
|
10
|
+
import { Api } from "telegram/tl"
|
|
11
|
+
|
|
12
|
+
describe("Phase 5 — Model & Agent Selection", () => {
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await setup()
|
|
15
|
+
}, 90000)
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await teardown()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// --- /model flow ---
|
|
22
|
+
|
|
23
|
+
test(
|
|
24
|
+
"/model shows provider buttons",
|
|
25
|
+
async () => {
|
|
26
|
+
const client = getClient()
|
|
27
|
+
const bot = getBotUsername()
|
|
28
|
+
const reply = await sendAndWait(client, bot, "/model", 15000)
|
|
29
|
+
const text = reply.text ?? reply.message ?? ""
|
|
30
|
+
expect(text.length).toBeGreaterThan(0)
|
|
31
|
+
assertHasButtons(reply)
|
|
32
|
+
},
|
|
33
|
+
30000,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
test(
|
|
37
|
+
"clicking provider shows model list with Back button",
|
|
38
|
+
async () => {
|
|
39
|
+
const client = getClient()
|
|
40
|
+
const bot = getBotUsername()
|
|
41
|
+
|
|
42
|
+
// Send /model to get provider list
|
|
43
|
+
const providerMsg = await sendAndWait(client, bot, "/model", 15000)
|
|
44
|
+
assertHasButtons(providerMsg)
|
|
45
|
+
|
|
46
|
+
// Get the first provider button text
|
|
47
|
+
const markup = providerMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
48
|
+
const firstButton = markup.rows[0].buttons[0]
|
|
49
|
+
const providerName = firstButton.text
|
|
50
|
+
|
|
51
|
+
// Click the first provider
|
|
52
|
+
const modelMsg = await clickAndWaitEdit(
|
|
53
|
+
client,
|
|
54
|
+
bot,
|
|
55
|
+
providerMsg.id,
|
|
56
|
+
providerName,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// Should now show model list
|
|
60
|
+
const text = modelMsg.text ?? modelMsg.message ?? ""
|
|
61
|
+
expect(text).toContain("Models for")
|
|
62
|
+
assertHasButtons(modelMsg)
|
|
63
|
+
|
|
64
|
+
// Should have a Back button
|
|
65
|
+
const modelMarkup = modelMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
66
|
+
const allButtons = modelMarkup.rows.flatMap((r) => r.buttons)
|
|
67
|
+
const hasBack = allButtons.some((b) => b.text.includes("Back"))
|
|
68
|
+
expect(hasBack).toBe(true)
|
|
69
|
+
},
|
|
70
|
+
30000,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
test(
|
|
74
|
+
"clicking model stores override and shows confirmation",
|
|
75
|
+
async () => {
|
|
76
|
+
const client = getClient()
|
|
77
|
+
const bot = getBotUsername()
|
|
78
|
+
|
|
79
|
+
// Send /model → click first provider → click first model
|
|
80
|
+
const providerMsg = await sendAndWait(client, bot, "/model", 15000)
|
|
81
|
+
const provMarkup = providerMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
82
|
+
const provButton = provMarkup.rows[0].buttons[0]
|
|
83
|
+
|
|
84
|
+
const modelMsg = await clickAndWaitEdit(
|
|
85
|
+
client,
|
|
86
|
+
bot,
|
|
87
|
+
providerMsg.id,
|
|
88
|
+
provButton.text,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// Click the first model (not Back)
|
|
92
|
+
const mdlMarkup = modelMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
93
|
+
const modelButton = mdlMarkup.rows[0].buttons[0]
|
|
94
|
+
// Skip if first row is Back button
|
|
95
|
+
const nonBackButton = mdlMarkup.rows
|
|
96
|
+
.flatMap((r) => r.buttons)
|
|
97
|
+
.find((b) => !b.text.includes("Back"))
|
|
98
|
+
|
|
99
|
+
expect(nonBackButton).toBeDefined()
|
|
100
|
+
|
|
101
|
+
const confirmMsg = await clickAndWaitEdit(
|
|
102
|
+
client,
|
|
103
|
+
bot,
|
|
104
|
+
providerMsg.id,
|
|
105
|
+
nonBackButton!.text,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const confirmText = confirmMsg.text ?? confirmMsg.message ?? ""
|
|
109
|
+
expect(confirmText).toContain("Model set to")
|
|
110
|
+
},
|
|
111
|
+
30000,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// --- /agent flow ---
|
|
115
|
+
|
|
116
|
+
test(
|
|
117
|
+
"/agent shows agent buttons with Reset",
|
|
118
|
+
async () => {
|
|
119
|
+
const client = getClient()
|
|
120
|
+
const bot = getBotUsername()
|
|
121
|
+
const reply = await sendAndWait(client, bot, "/agent", 15000)
|
|
122
|
+
const text = reply.text ?? reply.message ?? ""
|
|
123
|
+
expect(text.length).toBeGreaterThan(0)
|
|
124
|
+
assertHasButtons(reply)
|
|
125
|
+
|
|
126
|
+
// Should have Reset button
|
|
127
|
+
const markup = reply.replyMarkup as Api.ReplyInlineMarkup
|
|
128
|
+
const allButtons = markup.rows.flatMap((r) => r.buttons)
|
|
129
|
+
const hasReset = allButtons.some((b) => b.text.includes("Reset"))
|
|
130
|
+
expect(hasReset).toBe(true)
|
|
131
|
+
},
|
|
132
|
+
30000,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
test(
|
|
136
|
+
"clicking agent stores override and shows confirmation",
|
|
137
|
+
async () => {
|
|
138
|
+
const client = getClient()
|
|
139
|
+
const bot = getBotUsername()
|
|
140
|
+
|
|
141
|
+
const agentMsg = await sendAndWait(client, bot, "/agent", 15000)
|
|
142
|
+
const markup = agentMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
143
|
+
|
|
144
|
+
// Click the first non-Reset agent
|
|
145
|
+
const agentButton = markup.rows
|
|
146
|
+
.flatMap((r) => r.buttons)
|
|
147
|
+
.find((b) => !b.text.includes("Reset"))
|
|
148
|
+
|
|
149
|
+
expect(agentButton).toBeDefined()
|
|
150
|
+
|
|
151
|
+
const confirmMsg = await clickAndWaitEdit(
|
|
152
|
+
client,
|
|
153
|
+
bot,
|
|
154
|
+
agentMsg.id,
|
|
155
|
+
agentButton!.text,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const confirmText = confirmMsg.text ?? confirmMsg.message ?? ""
|
|
159
|
+
expect(confirmText).toContain("Agent set to")
|
|
160
|
+
},
|
|
161
|
+
30000,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
test(
|
|
165
|
+
"clicking Reset clears agent override",
|
|
166
|
+
async () => {
|
|
167
|
+
const client = getClient()
|
|
168
|
+
const bot = getBotUsername()
|
|
169
|
+
|
|
170
|
+
const agentMsg = await sendAndWait(client, bot, "/agent", 15000)
|
|
171
|
+
const resetMsg = await clickAndWaitEdit(
|
|
172
|
+
client,
|
|
173
|
+
bot,
|
|
174
|
+
agentMsg.id,
|
|
175
|
+
"Reset",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const text = resetMsg.text ?? resetMsg.message ?? ""
|
|
179
|
+
expect(text).toContain("reset to default")
|
|
180
|
+
},
|
|
181
|
+
30000,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
// --- model override integration ---
|
|
185
|
+
|
|
186
|
+
test(
|
|
187
|
+
"selecting model then sending message gets AI response",
|
|
188
|
+
async () => {
|
|
189
|
+
const client = getClient()
|
|
190
|
+
const bot = getBotUsername()
|
|
191
|
+
|
|
192
|
+
// 1. /new to start fresh
|
|
193
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
194
|
+
|
|
195
|
+
// 2. /model → pick first provider → pick first model
|
|
196
|
+
const providerMsg = await sendAndWait(client, bot, "/model", 15000)
|
|
197
|
+
assertHasButtons(providerMsg)
|
|
198
|
+
|
|
199
|
+
const provMarkup = providerMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
200
|
+
const provButton = provMarkup.rows[0].buttons[0]
|
|
201
|
+
|
|
202
|
+
const modelMsg = await clickAndWaitEdit(
|
|
203
|
+
client,
|
|
204
|
+
bot,
|
|
205
|
+
providerMsg.id,
|
|
206
|
+
provButton.text,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const mdlMarkup = modelMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
210
|
+
const nonBackButton = mdlMarkup.rows
|
|
211
|
+
.flatMap((r) => r.buttons)
|
|
212
|
+
.find((b) => !b.text.includes("Back"))
|
|
213
|
+
|
|
214
|
+
expect(nonBackButton).toBeDefined()
|
|
215
|
+
|
|
216
|
+
const confirmMsg = await clickAndWaitEdit(
|
|
217
|
+
client,
|
|
218
|
+
bot,
|
|
219
|
+
providerMsg.id,
|
|
220
|
+
nonBackButton!.text,
|
|
221
|
+
)
|
|
222
|
+
const confirmText = confirmMsg.text ?? confirmMsg.message ?? ""
|
|
223
|
+
expect(confirmText).toContain("Model set to")
|
|
224
|
+
|
|
225
|
+
// 3. Now send a text message — should work with the override
|
|
226
|
+
const reply = await sendAndWait(
|
|
227
|
+
client,
|
|
228
|
+
bot,
|
|
229
|
+
"Respond with exactly the single word: pong",
|
|
230
|
+
90000,
|
|
231
|
+
)
|
|
232
|
+
expect(reply).toBeDefined()
|
|
233
|
+
const text = reply.text ?? reply.message ?? ""
|
|
234
|
+
expect(text.length).toBeGreaterThan(0)
|
|
235
|
+
},
|
|
236
|
+
180000,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
test(
|
|
240
|
+
"model override persists after /new",
|
|
241
|
+
async () => {
|
|
242
|
+
const client = getClient()
|
|
243
|
+
const bot = getBotUsername()
|
|
244
|
+
|
|
245
|
+
// Model was set in previous test. Do /new to create fresh session.
|
|
246
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
247
|
+
|
|
248
|
+
// Send a message — should still work with the overridden model
|
|
249
|
+
const reply = await sendAndWait(
|
|
250
|
+
client,
|
|
251
|
+
bot,
|
|
252
|
+
"Respond with exactly the single word: pong",
|
|
253
|
+
90000,
|
|
254
|
+
)
|
|
255
|
+
expect(reply).toBeDefined()
|
|
256
|
+
const text = reply.text ?? reply.message ?? ""
|
|
257
|
+
expect(text.length).toBeGreaterThan(0)
|
|
258
|
+
|
|
259
|
+
// Verify override is still shown in /model
|
|
260
|
+
const modelMsg = await sendAndWait(client, bot, "/model", 15000)
|
|
261
|
+
const modelText = modelMsg.text ?? modelMsg.message ?? ""
|
|
262
|
+
expect(modelText).toContain("Current model:")
|
|
263
|
+
},
|
|
264
|
+
180000,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
// --- regression ---
|
|
268
|
+
|
|
269
|
+
test(
|
|
270
|
+
"regression: text message still works after model/agent commands",
|
|
271
|
+
async () => {
|
|
272
|
+
const client = getClient()
|
|
273
|
+
const bot = getBotUsername()
|
|
274
|
+
|
|
275
|
+
// Reset model to default first
|
|
276
|
+
const modelMsg = await sendAndWait(client, bot, "/model", 15000)
|
|
277
|
+
assertHasButtons(modelMsg)
|
|
278
|
+
// Look for Reset button — if model was overridden, there should be one
|
|
279
|
+
// (But in E2E the mdl:reset callback is handled)
|
|
280
|
+
|
|
281
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
282
|
+
|
|
283
|
+
const reply = await sendAndWait(
|
|
284
|
+
client,
|
|
285
|
+
bot,
|
|
286
|
+
"Respond with exactly the single word: pong",
|
|
287
|
+
60000,
|
|
288
|
+
)
|
|
289
|
+
expect(reply).toBeDefined()
|
|
290
|
+
const text = reply.text ?? reply.message ?? ""
|
|
291
|
+
expect(text.length).toBeGreaterThan(0)
|
|
292
|
+
},
|
|
293
|
+
120000,
|
|
294
|
+
)
|
|
295
|
+
})
|