@pedrohnas/opencode-telegram 1.2.0 → 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.
Files changed (53) hide show
  1. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-41-55-194Z.yml +36 -0
  2. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-42-17-115Z.yml +36 -0
  3. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-15-988Z.yml +26 -0
  4. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-26-107Z.yml +26 -0
  5. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-03-139Z.yml +29 -0
  6. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-21-579Z.yml +29 -0
  7. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-48-051Z.yml +30 -0
  8. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-27-632Z.yml +33 -0
  9. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-46-519Z.yml +33 -0
  10. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-28-491Z.yml +349 -0
  11. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-34-834Z.yml +349 -0
  12. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-54-066Z.yml +168 -0
  13. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-48-19-667Z.yml +219 -0
  14. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-32-311Z.yml +221 -0
  15. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-57-109Z.yml +230 -0
  16. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-24-052Z.yml +235 -0
  17. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-41-148Z.yml +248 -0
  18. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-10-916Z.yml +234 -0
  19. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-28-271Z.yml +234 -0
  20. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-32-324Z.yml +234 -0
  21. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-47-801Z.yml +196 -0
  22. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-07-361Z.yml +203 -0
  23. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-35-534Z.yml +49 -0
  24. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-57-04-658Z.yml +52 -0
  25. package/docs/AUDIT.md +193 -0
  26. package/docs/PROGRESS.md +188 -0
  27. package/docs/plans/phase-5.md +410 -0
  28. package/docs/plans/phase-6.5.md +426 -0
  29. package/docs/plans/phase-6.md +349 -0
  30. package/e2e/helpers.ts +34 -0
  31. package/e2e/phase-5.test.ts +295 -0
  32. package/e2e/phase-6.5.test.ts +239 -0
  33. package/e2e/phase-6.test.ts +302 -0
  34. package/package.json +6 -3
  35. package/src/api-server.test.ts +309 -0
  36. package/src/api-server.ts +201 -0
  37. package/src/bot.test.ts +354 -0
  38. package/src/bot.ts +200 -2
  39. package/src/config.test.ts +16 -0
  40. package/src/config.ts +4 -0
  41. package/src/event-bus.test.ts +337 -1
  42. package/src/event-bus.ts +83 -3
  43. package/src/handlers/agents.test.ts +122 -0
  44. package/src/handlers/agents.ts +93 -0
  45. package/src/handlers/media.test.ts +264 -0
  46. package/src/handlers/media.ts +168 -0
  47. package/src/handlers/models.test.ts +319 -0
  48. package/src/handlers/models.ts +191 -0
  49. package/src/index.ts +15 -0
  50. package/src/send/draft-stream.test.ts +76 -0
  51. package/src/send/draft-stream.ts +13 -1
  52. package/src/session-manager.test.ts +46 -0
  53. package/src/session-manager.ts +10 -1
package/src/bot.test.ts CHANGED
@@ -39,6 +39,32 @@ describe("START_MESSAGE", () => {
39
39
  })
40
40
  })
41
41
 
42
+ // --- Phase 6.5: sequentialize middleware ---
43
+
44
+ describe("sequentialize middleware", () => {
45
+ test("bot with sequentialize still creates successfully", () => {
46
+ const bot = createBot(testConfig)
47
+ expect(bot).toBeDefined()
48
+ // sequentialize is first middleware — bot should have more handlers than without it
49
+ })
50
+
51
+ test("sequentialize import is valid", async () => {
52
+ const { sequentialize } = await import("@grammyjs/runner")
53
+ expect(sequentialize).toBeFunction()
54
+ // Verify key function works
55
+ const resolver = (ctx: any) => String(ctx.chat?.id ?? "")
56
+ expect(resolver({ chat: { id: 123 } })).toBe("123")
57
+ expect(resolver({})).toBe("")
58
+ })
59
+
60
+ test("apiThrottler import is valid", async () => {
61
+ const { apiThrottler } = await import("@grammyjs/transformer-throttler")
62
+ expect(apiThrottler).toBeFunction()
63
+ const throttler = apiThrottler()
64
+ expect(throttler).toBeFunction()
65
+ })
66
+ })
67
+
42
68
  // --- Phase 1 tests: handleMessage and handleNew ---
43
69
 
44
70
  describe("handleMessage", () => {
@@ -178,6 +204,66 @@ describe("handleNew", () => {
178
204
  expect(sm.get("456")!.sessionId).toBe("fresh-session")
179
205
  expect(result.sessionId).toBe("fresh-session")
180
206
  })
207
+
208
+ test("preserves model/agent overrides across /new", async () => {
209
+ const { handleNew } = await import("./bot")
210
+ const createMock = mock(async () => ({
211
+ data: { id: "new-session-2", directory: "/tmp" },
212
+ }))
213
+
214
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
215
+ sm.set("123", {
216
+ sessionId: "old-session",
217
+ directory: "/tmp",
218
+ modelOverride: { providerID: "google", modelID: "gemini-flash" },
219
+ agentOverride: "code",
220
+ })
221
+
222
+ const sdk = {
223
+ session: { create: createMock },
224
+ } as any
225
+
226
+ await handleNew({ chatId: 123, sdk, sessionManager: sm })
227
+
228
+ const entry = sm.get("123")!
229
+ expect(entry.sessionId).toBe("new-session-2")
230
+ expect(entry.modelOverride).toEqual({ providerID: "google", modelID: "gemini-flash" })
231
+ expect(entry.agentOverride).toBe("code")
232
+ })
233
+ })
234
+
235
+ describe("handleSessionCallback — override preservation", () => {
236
+ test("preserves model/agent overrides when switching session", async () => {
237
+ const { handleSessionCallback } = await import("./bot")
238
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
239
+ sm.set("123", {
240
+ sessionId: "old-session",
241
+ directory: "/tmp",
242
+ modelOverride: { providerID: "anthropic", modelID: "claude-opus" },
243
+ agentOverride: "build",
244
+ })
245
+
246
+ const sdk = {
247
+ session: {
248
+ list: mock(async () => ({
249
+ data: [{ id: "target-session-full", title: "Target", directory: "/proj" }],
250
+ })),
251
+ },
252
+ } as any
253
+
254
+ const result = await handleSessionCallback({
255
+ chatKey: "123",
256
+ sessionPrefix: "target-session",
257
+ sdk,
258
+ sessionManager: sm,
259
+ })
260
+
261
+ expect(result).toContain("Switched to")
262
+ const entry = sm.get("123")!
263
+ expect(entry.sessionId).toBe("target-session-full")
264
+ expect(entry.modelOverride).toEqual({ providerID: "anthropic", modelID: "claude-opus" })
265
+ expect(entry.agentOverride).toBe("build")
266
+ })
181
267
  })
182
268
 
183
269
  // --- Phase 4 tests: handleMessage abort, session commands ---
@@ -299,3 +385,271 @@ describe("handleSessionCallback", () => {
299
385
  expect(result).toBe("Session not found.")
300
386
  })
301
387
  })
388
+
389
+ // --- Phase 5: model/agent override passing ---
390
+
391
+ describe("handleMessage — model/agent overrides", () => {
392
+ test("passes modelOverride to sdk.session.prompt when set", async () => {
393
+ const { handleMessage } = await import("./bot")
394
+ const promptMock = mock(async () => ({ data: {} }))
395
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
396
+ const tm = new TurnManager()
397
+
398
+ sm.set("123", {
399
+ sessionId: "s1",
400
+ directory: "/tmp",
401
+ modelOverride: { providerID: "anthropic", modelID: "claude-opus" },
402
+ })
403
+ const sdk = {
404
+ session: {
405
+ prompt: promptMock,
406
+ create: mock(async () => ({ data: { id: "s1" } })),
407
+ abort: mock(async () => ({})),
408
+ },
409
+ } as any
410
+
411
+ await handleMessage({
412
+ chatId: 123,
413
+ text: "hello",
414
+ sdk,
415
+ sessionManager: sm,
416
+ turnManager: tm,
417
+ })
418
+
419
+ await new Promise((r) => setTimeout(r, 10))
420
+ const call = promptMock.mock.calls[0]![0] as any
421
+ expect(call.model).toEqual({ providerID: "anthropic", modelID: "claude-opus" })
422
+ })
423
+
424
+ test("passes agentOverride to sdk.session.prompt when set", async () => {
425
+ const { handleMessage } = await import("./bot")
426
+ const promptMock = mock(async () => ({ data: {} }))
427
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
428
+ const tm = new TurnManager()
429
+
430
+ sm.set("123", {
431
+ sessionId: "s1",
432
+ directory: "/tmp",
433
+ agentOverride: "code",
434
+ })
435
+ const sdk = {
436
+ session: {
437
+ prompt: promptMock,
438
+ create: mock(async () => ({ data: { id: "s1" } })),
439
+ abort: mock(async () => ({})),
440
+ },
441
+ } as any
442
+
443
+ await handleMessage({
444
+ chatId: 123,
445
+ text: "hello",
446
+ sdk,
447
+ sessionManager: sm,
448
+ turnManager: tm,
449
+ })
450
+
451
+ await new Promise((r) => setTimeout(r, 10))
452
+ const call = promptMock.mock.calls[0]![0] as any
453
+ expect(call.agent).toBe("code")
454
+ })
455
+
456
+ test("does NOT pass model/agent when overrides not set", async () => {
457
+ const { handleMessage } = await import("./bot")
458
+ const promptMock = mock(async () => ({ data: {} }))
459
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
460
+ const tm = new TurnManager()
461
+
462
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
463
+ const sdk = {
464
+ session: {
465
+ prompt: promptMock,
466
+ create: mock(async () => ({ data: { id: "s1" } })),
467
+ abort: mock(async () => ({})),
468
+ },
469
+ } as any
470
+
471
+ await handleMessage({
472
+ chatId: 123,
473
+ text: "hello",
474
+ sdk,
475
+ sessionManager: sm,
476
+ turnManager: tm,
477
+ })
478
+
479
+ await new Promise((r) => setTimeout(r, 10))
480
+ const call = promptMock.mock.calls[0]![0] as any
481
+ expect(call.model).toBeUndefined()
482
+ expect(call.agent).toBeUndefined()
483
+ })
484
+
485
+ test("passes both overrides simultaneously", async () => {
486
+ const { handleMessage } = await import("./bot")
487
+ const promptMock = mock(async () => ({ data: {} }))
488
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
489
+ const tm = new TurnManager()
490
+
491
+ sm.set("123", {
492
+ sessionId: "s1",
493
+ directory: "/tmp",
494
+ modelOverride: { providerID: "openai", modelID: "gpt-4o" },
495
+ agentOverride: "build",
496
+ })
497
+ const sdk = {
498
+ session: {
499
+ prompt: promptMock,
500
+ create: mock(async () => ({ data: { id: "s1" } })),
501
+ abort: mock(async () => ({})),
502
+ },
503
+ } as any
504
+
505
+ await handleMessage({
506
+ chatId: 123,
507
+ text: "hello",
508
+ sdk,
509
+ sessionManager: sm,
510
+ turnManager: tm,
511
+ })
512
+
513
+ await new Promise((r) => setTimeout(r, 10))
514
+ const call = promptMock.mock.calls[0]![0] as any
515
+ expect(call.model).toEqual({ providerID: "openai", modelID: "gpt-4o" })
516
+ expect(call.agent).toBe("build")
517
+ })
518
+ })
519
+
520
+ // --- Phase 6: media parts passthrough ---
521
+
522
+ describe("handleMessage — media parts", () => {
523
+ test("uses explicit parts array when provided", async () => {
524
+ const { handleMessage } = await import("./bot")
525
+ const promptMock = mock(async () => ({ data: {} }))
526
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
527
+ const tm = new TurnManager()
528
+
529
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
530
+ const sdk = {
531
+ session: {
532
+ prompt: promptMock,
533
+ create: mock(async () => ({ data: { id: "s1" } })),
534
+ abort: mock(async () => ({})),
535
+ },
536
+ } as any
537
+
538
+ const customParts = [
539
+ { type: "text" as const, text: "What is this?" },
540
+ { type: "file" as const, mime: "image/jpeg", url: "data:image/jpeg;base64,abc", filename: "photo.jpg" },
541
+ ]
542
+
543
+ await handleMessage({
544
+ chatId: 123,
545
+ text: "",
546
+ parts: customParts,
547
+ sdk,
548
+ sessionManager: sm,
549
+ turnManager: tm,
550
+ })
551
+
552
+ await new Promise((r) => setTimeout(r, 10))
553
+ const call = promptMock.mock.calls[0]![0] as any
554
+ expect(call.parts).toHaveLength(2)
555
+ expect(call.parts[0].type).toBe("text")
556
+ expect(call.parts[1].type).toBe("file")
557
+ expect(call.parts[1].mime).toBe("image/jpeg")
558
+ })
559
+
560
+ test("without parts builds text part from text param (backward compat)", async () => {
561
+ const { handleMessage } = await import("./bot")
562
+ const promptMock = mock(async () => ({ data: {} }))
563
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
564
+ const tm = new TurnManager()
565
+
566
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
567
+ const sdk = {
568
+ session: {
569
+ prompt: promptMock,
570
+ create: mock(async () => ({ data: { id: "s1" } })),
571
+ abort: mock(async () => ({})),
572
+ },
573
+ } as any
574
+
575
+ await handleMessage({
576
+ chatId: 123,
577
+ text: "hello world",
578
+ sdk,
579
+ sessionManager: sm,
580
+ turnManager: tm,
581
+ })
582
+
583
+ await new Promise((r) => setTimeout(r, 10))
584
+ const call = promptMock.mock.calls[0]![0] as any
585
+ expect(call.parts).toHaveLength(1)
586
+ expect(call.parts[0].type).toBe("text")
587
+ expect(call.parts[0].text).toBe("hello world")
588
+ })
589
+
590
+ test("with both text and parts — parts wins", async () => {
591
+ const { handleMessage } = await import("./bot")
592
+ const promptMock = mock(async () => ({ data: {} }))
593
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
594
+ const tm = new TurnManager()
595
+
596
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
597
+ const sdk = {
598
+ session: {
599
+ prompt: promptMock,
600
+ create: mock(async () => ({ data: { id: "s1" } })),
601
+ abort: mock(async () => ({})),
602
+ },
603
+ } as any
604
+
605
+ const customParts = [
606
+ { type: "file" as const, mime: "image/png", url: "data:image/png;base64,xyz", filename: "img.png" },
607
+ ]
608
+
609
+ await handleMessage({
610
+ chatId: 123,
611
+ text: "this should be ignored",
612
+ parts: customParts,
613
+ sdk,
614
+ sessionManager: sm,
615
+ turnManager: tm,
616
+ })
617
+
618
+ await new Promise((r) => setTimeout(r, 10))
619
+ const call = promptMock.mock.calls[0]![0] as any
620
+ expect(call.parts).toHaveLength(1)
621
+ expect(call.parts[0].type).toBe("file")
622
+ })
623
+
624
+ test("with empty parts array still works", async () => {
625
+ const { handleMessage } = await import("./bot")
626
+ const promptMock = mock(async () => ({ data: {} }))
627
+ const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
628
+ const tm = new TurnManager()
629
+
630
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
631
+ const sdk = {
632
+ session: {
633
+ prompt: promptMock,
634
+ create: mock(async () => ({ data: { id: "s1" } })),
635
+ abort: mock(async () => ({})),
636
+ },
637
+ } as any
638
+
639
+ await handleMessage({
640
+ chatId: 123,
641
+ text: "fallback text",
642
+ parts: [],
643
+ sdk,
644
+ sessionManager: sm,
645
+ turnManager: tm,
646
+ })
647
+
648
+ await new Promise((r) => setTimeout(r, 10))
649
+ const call = promptMock.mock.calls[0]![0] as any
650
+ // Empty parts array → falls back to text
651
+ expect(call.parts).toHaveLength(1)
652
+ expect(call.parts[0].type).toBe("text")
653
+ expect(call.parts[0].text).toBe("fallback text")
654
+ })
655
+ })
package/src/bot.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Bot } from "grammy"
11
+ import { sequentialize } from "@grammyjs/runner"
11
12
  import type { Config } from "./config"
12
13
  import type { OpencodeClient } from "@opencode-ai/sdk/v2"
13
14
  import type { SessionManager } from "./session-manager"
@@ -31,6 +32,25 @@ import {
31
32
  handleSummarize,
32
33
  parseSessionCallback,
33
34
  } from "./handlers/sessions"
35
+ import {
36
+ handleModel,
37
+ handleModelSelect,
38
+ parseModelCallback,
39
+ formatProviderList,
40
+ formatModelList,
41
+ filterModels,
42
+ } from "./handlers/models"
43
+ import {
44
+ handleAgent,
45
+ handleAgentSelect,
46
+ parseAgentCallback,
47
+ } from "./handlers/agents"
48
+ import {
49
+ extractFileRef,
50
+ downloadTelegramFile,
51
+ buildMediaParts,
52
+ getMimeFromFileName,
53
+ } from "./handlers/media"
34
54
 
35
55
  export const START_MESSAGE = [
36
56
  "OpenCode Telegram Bot",
@@ -50,7 +70,10 @@ export type BotDeps = {
50
70
  export function createBot(config: Config, deps?: BotDeps) {
51
71
  const bot = new Bot(config.botToken)
52
72
 
53
- // Allowlist middleware must be first (blocks unauthorized users)
73
+ // Sequentializeensures per-chat serial processing (prevents race conditions)
74
+ bot.use(sequentialize((ctx) => String(ctx.chat?.id ?? "")))
75
+
76
+ // Allowlist middleware — blocks unauthorized users
54
77
  bot.use(createAllowlistMiddleware(config.allowedUsers, config.allowAllUsers))
55
78
 
56
79
  bot.command("start", async (ctx) => {
@@ -102,6 +125,18 @@ export function createBot(config: Config, deps?: BotDeps) {
102
125
  await ctx.reply(result)
103
126
  })
104
127
 
128
+ bot.command("model", async (ctx) => {
129
+ const chatKey = String(ctx.chat.id)
130
+ const result = await handleModel({ sdk, sessionManager, chatKey })
131
+ await ctx.reply(result.text, { reply_markup: result.reply_markup })
132
+ })
133
+
134
+ bot.command("agent", async (ctx) => {
135
+ const chatKey = String(ctx.chat.id)
136
+ const result = await handleAgent({ sdk, sessionManager, chatKey })
137
+ await ctx.reply(result.text, { reply_markup: result.reply_markup })
138
+ })
139
+
105
140
  bot.command("cancel", async (ctx) => {
106
141
  const chatId = ctx.chat.id
107
142
  const result = await handleCancel({
@@ -179,8 +214,147 @@ export function createBot(config: Config, deps?: BotDeps) {
179
214
  await ctx.editMessageText(result)
180
215
  return
181
216
  }
217
+
218
+ if (data.startsWith("mdl:")) {
219
+ const parsed = parseModelCallback(data)
220
+ if (!parsed) return
221
+ const chatKey = String(ctx.from.id)
222
+
223
+ if (parsed.type === "reset") {
224
+ const entry = sessionManager.get(chatKey)
225
+ if (entry) {
226
+ sessionManager.set(chatKey, { ...entry, modelOverride: undefined })
227
+ }
228
+ await ctx.editMessageText("Model reset to default.")
229
+ return
230
+ }
231
+
232
+ if (parsed.type === "back") {
233
+ const result = await handleModel({ sdk, sessionManager, chatKey })
234
+ await ctx.editMessageText(result.text, { reply_markup: result.reply_markup })
235
+ return
236
+ }
237
+
238
+ if (parsed.type === "provider") {
239
+ const provResult = await sdk.provider.list()
240
+ const data = (provResult as any).data ?? {}
241
+ const providers = data.all ?? []
242
+ const defaults = data.default ?? {}
243
+ const provider = providers.find((p: any) => p.id === parsed.providerID)
244
+ if (!provider) {
245
+ await ctx.editMessageText("Provider not found.")
246
+ return
247
+ }
248
+ const allModels = Object.values(provider.models ?? {}) as any[]
249
+ const filtered = filterModels(allModels)
250
+ const entry = sessionManager.get(chatKey)
251
+ const activeModelID =
252
+ entry?.modelOverride?.providerID === parsed.providerID
253
+ ? entry.modelOverride.modelID
254
+ : defaults[parsed.providerID]
255
+ const result = formatModelList(parsed.providerID, provider.name || parsed.providerID, filtered, activeModelID)
256
+ await ctx.editMessageText(result.text, { reply_markup: result.reply_markup })
257
+ return
258
+ }
259
+
260
+ if (parsed.type === "model") {
261
+ const result = await handleModelSelect({
262
+ chatKey,
263
+ providerID: parsed.providerID,
264
+ modelID: parsed.modelID,
265
+ sessionManager,
266
+ })
267
+ await ctx.editMessageText(result)
268
+ return
269
+ }
270
+ }
271
+
272
+ if (data.startsWith("agt:")) {
273
+ const parsed = parseAgentCallback(data)
274
+ if (!parsed) return
275
+ const chatKey = String(ctx.from.id)
276
+
277
+ if ("action" in parsed && parsed.action === "reset") {
278
+ const entry = sessionManager.get(chatKey)
279
+ if (entry) {
280
+ sessionManager.set(chatKey, { ...entry, agentOverride: undefined })
281
+ }
282
+ await ctx.editMessageText("Agent reset to default.")
283
+ return
284
+ }
285
+
286
+ if ("name" in parsed) {
287
+ const result = await handleAgentSelect({
288
+ chatKey,
289
+ agentName: parsed.name,
290
+ sessionManager,
291
+ })
292
+ await ctx.editMessageText(result)
293
+ return
294
+ }
295
+ }
182
296
  })
183
297
 
298
+ // --- Media handlers (must come BEFORE message:text) ---
299
+ const handleMedia = async (ctx: any) => {
300
+ const chatId = ctx.chat.id
301
+ const msg = ctx.message
302
+ const ref = extractFileRef(msg)
303
+ if (!ref) return
304
+
305
+ try {
306
+ const downloaded = await downloadTelegramFile({
307
+ fileId: ref.fileId,
308
+ token: config.botToken,
309
+ getFile: (fid: string) => ctx.api.getFile(fid),
310
+ filename: ref.filename,
311
+ mime: ref.mime,
312
+ })
313
+
314
+ if (!downloaded) {
315
+ await ctx.reply("Could not download file. It may be too large (max 20MB).")
316
+ return
317
+ }
318
+
319
+ const parts = buildMediaParts({
320
+ buffer: downloaded.buffer,
321
+ mime: downloaded.mime,
322
+ filename: downloaded.filename,
323
+ caption: msg.caption,
324
+ })
325
+
326
+ const { turn } = await handleMessage({
327
+ chatId,
328
+ text: msg.caption ?? "",
329
+ parts,
330
+ sdk,
331
+ sessionManager,
332
+ turnManager,
333
+ draftDeps: {
334
+ sendMessage: (id, t, o) =>
335
+ bot.api.sendMessage(id, t, o as any),
336
+ editMessageText: (id, m, t, o) =>
337
+ bot.api.editMessageText(id, m, t, o as any),
338
+ },
339
+ })
340
+
341
+ startTypingLoop(
342
+ chatId,
343
+ (id, action) => bot.api.sendChatAction(id, action as any),
344
+ turn.abortController.signal,
345
+ )
346
+ } catch (err) {
347
+ console.error("Media handler error:", err)
348
+ await ctx.reply("Error processing file.").catch(() => {})
349
+ }
350
+ }
351
+
352
+ bot.on("message:photo", handleMedia)
353
+ bot.on("message:document", handleMedia)
354
+ bot.on("message:voice", handleMedia)
355
+ bot.on("message:audio", handleMedia)
356
+ bot.on("message:video", handleMedia)
357
+
184
358
  bot.on("message:text", async (ctx) => {
185
359
  const chatId = ctx.chat.id
186
360
  const text = ctx.message.text.trim()
@@ -220,6 +394,7 @@ export function createBot(config: Config, deps?: BotDeps) {
220
394
  export async function handleMessage(params: {
221
395
  chatId: number
222
396
  text: string
397
+ parts?: Array<{ type: string; [k: string]: unknown }>
223
398
  sdk: OpencodeClient
224
399
  sessionManager: SessionManager
225
400
  turnManager: TurnManager
@@ -246,11 +421,18 @@ export async function handleMessage(params: {
246
421
  turn.draft = new DraftStream(draftDeps, chatId, turn.abortController.signal)
247
422
  }
248
423
 
424
+ // Use explicit parts if provided and non-empty, otherwise build from text
425
+ const promptParts = (params.parts && params.parts.length > 0)
426
+ ? params.parts
427
+ : [{ type: "text" as const, text }]
428
+
249
429
  // Fire-and-forget: don't block the Grammy handler.
250
430
  // The response comes via SSE events → DraftStream → finalizeResponse.
251
431
  sdk.session.prompt({
252
432
  sessionID: entry.sessionId,
253
- parts: [{ type: "text", text }],
433
+ parts: promptParts as any,
434
+ ...(entry.modelOverride && { model: entry.modelOverride }),
435
+ ...(entry.agentOverride && { agent: entry.agentOverride }),
254
436
  }).catch((err: unknown) => {
255
437
  console.error("Prompt error:", err)
256
438
  })
@@ -270,9 +452,14 @@ export async function handleSessionCallback(params: {
270
452
  const match = sessions.find((s: any) => s.id.startsWith(sessionPrefix))
271
453
  if (!match) return "Session not found."
272
454
 
455
+ // Preserve model/agent overrides across session switches
456
+ const oldEntry = sessionManager.get(chatKey)
457
+
273
458
  sessionManager.set(chatKey, {
274
459
  sessionId: match.id,
275
460
  directory: match.directory ?? "",
461
+ modelOverride: oldEntry?.modelOverride,
462
+ agentOverride: oldEntry?.agentOverride,
276
463
  })
277
464
  return `Switched to: ${match.title || match.id}`
278
465
  }
@@ -285,10 +472,21 @@ export async function handleNew(params: {
285
472
  const { chatId, sdk, sessionManager } = params
286
473
  const chatKey = String(chatId)
287
474
 
475
+ // Preserve model/agent overrides across session changes
476
+ const oldEntry = sessionManager.get(chatKey)
477
+ const modelOverride = oldEntry?.modelOverride
478
+ const agentOverride = oldEntry?.agentOverride
479
+
288
480
  // Remove existing session mapping (session persists on server)
289
481
  sessionManager.remove(chatKey)
290
482
 
291
483
  // Create fresh session
292
484
  const entry = await sessionManager.getOrCreate(chatKey, sdk)
485
+
486
+ // Restore overrides
487
+ if (modelOverride || agentOverride) {
488
+ sessionManager.set(chatKey, { ...entry, modelOverride, agentOverride })
489
+ }
490
+
293
491
  return entry
294
492
  }
@@ -127,4 +127,20 @@ describe("loadConfig", () => {
127
127
  const config = loadConfig()
128
128
  expect(config.allowAllUsers).toBe(false)
129
129
  })
130
+
131
+ test("apiPort defaults to 4097 when not set", async () => {
132
+ process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
133
+ delete process.env.TELEGRAM_API_PORT
134
+ const { loadConfig } = await import("./config?apiport1")
135
+ const config = loadConfig()
136
+ expect(config.apiPort).toBe(4097)
137
+ })
138
+
139
+ test("apiPort reads from TELEGRAM_API_PORT env var", async () => {
140
+ process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
141
+ process.env.TELEGRAM_API_PORT = "5555"
142
+ const { loadConfig } = await import("./config?apiport2")
143
+ const config = loadConfig()
144
+ expect(config.apiPort).toBe(5555)
145
+ })
130
146
  })
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ export type Config = {
5
5
  testEnv: boolean
6
6
  allowedUsers: number[]
7
7
  allowAllUsers: boolean
8
+ apiPort: number
8
9
  e2e: {
9
10
  apiId: number
10
11
  apiHash: string
@@ -30,6 +31,8 @@ export function loadConfig(): Config {
30
31
  .map(Number)
31
32
  .filter((n) => !isNaN(n))
32
33
 
34
+ const apiPort = Number(process.env.TELEGRAM_API_PORT) || 4097
35
+
33
36
  return {
34
37
  botToken,
35
38
  opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4096",
@@ -37,6 +40,7 @@ export function loadConfig(): Config {
37
40
  testEnv: process.env.TELEGRAM_TEST_ENV === "1",
38
41
  allowedUsers,
39
42
  allowAllUsers,
43
+ apiPort,
40
44
  e2e: {
41
45
  apiId: Number(process.env.TELEGRAM_API_ID ?? 0),
42
46
  apiHash: process.env.TELEGRAM_API_HASH ?? "",