@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
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
|
-
//
|
|
73
|
+
// Sequentialize — ensures 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:
|
|
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
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -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 ?? "",
|