@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
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Agent selection handlers — list agents → select → store override.
3
+ *
4
+ * Exports:
5
+ * - parseAgentCallback(data) — parse "agt:" callback data
6
+ * - formatAgentList(agents) — inline keyboard of agents
7
+ * - handleAgent(params) — fetch agents, return keyboard
8
+ * - handleAgentSelect(params) — store agent override in SessionEntry
9
+ */
10
+
11
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2"
12
+ import type { SessionManager } from "../session-manager"
13
+
14
+ // --- Pure functions ---
15
+
16
+ export type AgentCallbackResult =
17
+ | { name: string }
18
+ | { action: "reset" }
19
+
20
+ export function parseAgentCallback(data: string): AgentCallbackResult | null {
21
+ if (!data.startsWith("agt:")) return null
22
+ const rest = data.slice(4)
23
+ if (!rest) return null
24
+
25
+ if (rest === "reset") return { action: "reset" }
26
+ return { name: rest }
27
+ }
28
+
29
+ export function formatAgentList(agents: any[]): {
30
+ text: string
31
+ reply_markup: { inline_keyboard: any[][] }
32
+ } {
33
+ const visible = agents.filter((a) => !a.hidden)
34
+ const resetRow = [{ text: "Reset to default", callback_data: "agt:reset" }]
35
+
36
+ if (visible.length === 0) {
37
+ return {
38
+ text: "No agents available.",
39
+ reply_markup: { inline_keyboard: [resetRow] },
40
+ }
41
+ }
42
+
43
+ const rows = visible.map((a) => [
44
+ { text: a.name, callback_data: `agt:${a.name}` },
45
+ ])
46
+
47
+ rows.push(resetRow)
48
+
49
+ return {
50
+ text: "Select an agent:",
51
+ reply_markup: { inline_keyboard: rows },
52
+ }
53
+ }
54
+
55
+ // --- Async handlers ---
56
+
57
+ export async function handleAgent(params: {
58
+ sdk: OpencodeClient
59
+ sessionManager: SessionManager
60
+ chatKey: string
61
+ }): Promise<{ text: string; reply_markup: { inline_keyboard: any[][] } }> {
62
+ const { sdk, sessionManager, chatKey } = params
63
+ const result = await sdk.app.agents()
64
+ const agents = (result as any).data ?? []
65
+ const entry = sessionManager.get(chatKey)
66
+ const currentText = entry?.agentOverride
67
+ ? `Current agent: ${entry.agentOverride}`
68
+ : "Using default agent."
69
+
70
+ const list = formatAgentList(agents)
71
+
72
+ return {
73
+ text: `${currentText}\n\n${list.text}`,
74
+ reply_markup: list.reply_markup,
75
+ }
76
+ }
77
+
78
+ export async function handleAgentSelect(params: {
79
+ chatKey: string
80
+ agentName: string
81
+ sessionManager: SessionManager
82
+ }): Promise<string> {
83
+ const { chatKey, agentName, sessionManager } = params
84
+ const entry = sessionManager.get(chatKey)
85
+ if (!entry) return "No active session."
86
+
87
+ sessionManager.set(chatKey, {
88
+ ...entry,
89
+ agentOverride: agentName,
90
+ })
91
+
92
+ return `Agent set to: ${agentName}`
93
+ }
@@ -0,0 +1,264 @@
1
+ import { describe, test, expect, mock } from "bun:test"
2
+ import {
3
+ bufferToDataUrl,
4
+ buildFilePart,
5
+ buildMediaParts,
6
+ getMimeFromFileName,
7
+ extractFileRef,
8
+ downloadTelegramFile,
9
+ } from "./media"
10
+
11
+ // --- bufferToDataUrl ---
12
+
13
+ describe("bufferToDataUrl", () => {
14
+ test("converts buffer to correct data URL format", () => {
15
+ const buf = Buffer.from("hello world")
16
+ const result = bufferToDataUrl(buf, "text/plain")
17
+ expect(result).toBe(`data:text/plain;base64,${buf.toString("base64")}`)
18
+ })
19
+
20
+ test("handles empty buffer", () => {
21
+ const buf = Buffer.alloc(0)
22
+ const result = bufferToDataUrl(buf, "application/octet-stream")
23
+ expect(result).toBe("data:application/octet-stream;base64,")
24
+ })
25
+
26
+ test("handles different MIME types", () => {
27
+ const buf = Buffer.from([0xff, 0xd8, 0xff])
28
+ expect(bufferToDataUrl(buf, "image/jpeg")).toStartWith("data:image/jpeg;base64,")
29
+ expect(bufferToDataUrl(buf, "application/pdf")).toStartWith("data:application/pdf;base64,")
30
+ expect(bufferToDataUrl(buf, "audio/ogg")).toStartWith("data:audio/ogg;base64,")
31
+ })
32
+ })
33
+
34
+ // --- buildFilePart ---
35
+
36
+ describe("buildFilePart", () => {
37
+ test("creates FilePartInput with correct fields", () => {
38
+ const buf = Buffer.from("test")
39
+ const result = buildFilePart({ buffer: buf, mime: "image/png", filename: "test.png" })
40
+ expect(result.type).toBe("file")
41
+ expect(result.mime).toBe("image/png")
42
+ expect(result.filename).toBe("test.png")
43
+ expect(result.url).toContain("data:image/png;base64,")
44
+ })
45
+
46
+ test("URL is a valid data URL", () => {
47
+ const buf = Buffer.from("data")
48
+ const result = buildFilePart({ buffer: buf, mime: "text/plain", filename: "f.txt" })
49
+ expect(result.url).toMatch(/^data:[^;]+;base64,.+$/)
50
+ })
51
+ })
52
+
53
+ // --- buildMediaParts ---
54
+
55
+ describe("buildMediaParts", () => {
56
+ test("without caption returns [FilePartInput] only", () => {
57
+ const parts = buildMediaParts({
58
+ buffer: Buffer.from("img"),
59
+ mime: "image/jpeg",
60
+ filename: "photo.jpg",
61
+ })
62
+ expect(parts).toHaveLength(1)
63
+ expect(parts[0]!.type).toBe("file")
64
+ })
65
+
66
+ test("with caption returns [TextPartInput, FilePartInput]", () => {
67
+ const parts = buildMediaParts({
68
+ buffer: Buffer.from("img"),
69
+ mime: "image/jpeg",
70
+ filename: "photo.jpg",
71
+ caption: "What is this?",
72
+ })
73
+ expect(parts).toHaveLength(2)
74
+ expect(parts[0]!.type).toBe("text")
75
+ expect((parts[0] as any).text).toBe("What is this?")
76
+ expect(parts[1]!.type).toBe("file")
77
+ })
78
+
79
+ test("empty caption treated as no caption", () => {
80
+ const parts = buildMediaParts({
81
+ buffer: Buffer.from("img"),
82
+ mime: "image/jpeg",
83
+ filename: "photo.jpg",
84
+ caption: "",
85
+ })
86
+ expect(parts).toHaveLength(1)
87
+ expect(parts[0]!.type).toBe("file")
88
+ })
89
+ })
90
+
91
+ // --- getMimeFromFileName ---
92
+
93
+ describe("getMimeFromFileName", () => {
94
+ test(".jpg → image/jpeg", () => {
95
+ expect(getMimeFromFileName("photo.jpg")).toBe("image/jpeg")
96
+ })
97
+
98
+ test(".pdf → application/pdf", () => {
99
+ expect(getMimeFromFileName("doc.pdf")).toBe("application/pdf")
100
+ })
101
+
102
+ test(".py → text/x-python", () => {
103
+ expect(getMimeFromFileName("script.py")).toBe("text/x-python")
104
+ })
105
+
106
+ test("unknown extension → application/octet-stream", () => {
107
+ expect(getMimeFromFileName("data.xyz123")).toBe("application/octet-stream")
108
+ })
109
+ })
110
+
111
+ // --- extractFileRef ---
112
+
113
+ describe("extractFileRef", () => {
114
+ test("photo message picks last (highest res) photo", () => {
115
+ const msg = {
116
+ photo: [
117
+ { file_id: "small", file_unique_id: "s", width: 100, height: 100 },
118
+ { file_id: "medium", file_unique_id: "m", width: 320, height: 320 },
119
+ { file_id: "large", file_unique_id: "l", width: 1280, height: 1280 },
120
+ ],
121
+ }
122
+ const ref = extractFileRef(msg as any)
123
+ expect(ref).not.toBeNull()
124
+ expect(ref!.fileId).toBe("large")
125
+ expect(ref!.mime).toBe("image/jpeg")
126
+ })
127
+
128
+ test("document message returns file_id + file_name + mime_type", () => {
129
+ const msg = {
130
+ document: {
131
+ file_id: "doc123",
132
+ file_unique_id: "d",
133
+ file_name: "report.pdf",
134
+ mime_type: "application/pdf",
135
+ },
136
+ }
137
+ const ref = extractFileRef(msg as any)
138
+ expect(ref).not.toBeNull()
139
+ expect(ref!.fileId).toBe("doc123")
140
+ expect(ref!.filename).toBe("report.pdf")
141
+ expect(ref!.mime).toBe("application/pdf")
142
+ })
143
+
144
+ test("voice message returns file_id with audio/ogg mime", () => {
145
+ const msg = {
146
+ voice: { file_id: "voice1", file_unique_id: "v", duration: 5 },
147
+ }
148
+ const ref = extractFileRef(msg as any)
149
+ expect(ref).not.toBeNull()
150
+ expect(ref!.fileId).toBe("voice1")
151
+ expect(ref!.mime).toBe("audio/ogg")
152
+ })
153
+
154
+ test("audio message returns file_id + mime_type", () => {
155
+ const msg = {
156
+ audio: {
157
+ file_id: "audio1",
158
+ file_unique_id: "a",
159
+ duration: 120,
160
+ mime_type: "audio/mpeg",
161
+ },
162
+ }
163
+ const ref = extractFileRef(msg as any)
164
+ expect(ref).not.toBeNull()
165
+ expect(ref!.fileId).toBe("audio1")
166
+ expect(ref!.mime).toBe("audio/mpeg")
167
+ })
168
+
169
+ test("video message returns file_id with video/mp4 mime", () => {
170
+ const msg = {
171
+ video: {
172
+ file_id: "vid1",
173
+ file_unique_id: "v",
174
+ width: 1920,
175
+ height: 1080,
176
+ duration: 30,
177
+ },
178
+ }
179
+ const ref = extractFileRef(msg as any)
180
+ expect(ref).not.toBeNull()
181
+ expect(ref!.fileId).toBe("vid1")
182
+ expect(ref!.mime).toBe("video/mp4")
183
+ })
184
+
185
+ test("message with no media returns null", () => {
186
+ const msg = { text: "hello" }
187
+ const ref = extractFileRef(msg as any)
188
+ expect(ref).toBeNull()
189
+ })
190
+ })
191
+
192
+ // --- downloadTelegramFile ---
193
+
194
+ describe("downloadTelegramFile", () => {
195
+ test("downloads and returns buffer + mime + filename", async () => {
196
+ const mockGetFile = mock(async () => ({
197
+ file_path: "photos/photo.jpg",
198
+ file_size: 1024,
199
+ }))
200
+
201
+ const fileContent = Buffer.from("fake image data")
202
+ const originalFetch = globalThis.fetch
203
+ globalThis.fetch = mock(async () => ({
204
+ ok: true,
205
+ headers: new Headers({ "content-type": "image/jpeg" }),
206
+ arrayBuffer: async () => fileContent.buffer.slice(
207
+ fileContent.byteOffset,
208
+ fileContent.byteOffset + fileContent.byteLength,
209
+ ),
210
+ })) as any
211
+
212
+ try {
213
+ const result = await downloadTelegramFile({
214
+ fileId: "abc123",
215
+ token: "BOT_TOKEN",
216
+ getFile: mockGetFile,
217
+ filename: "photo.jpg",
218
+ mime: "image/jpeg",
219
+ })
220
+
221
+ expect(result).not.toBeNull()
222
+ expect(result!.mime).toBe("image/jpeg")
223
+ expect(result!.filename).toBe("photo.jpg")
224
+ expect(result!.buffer.length).toBeGreaterThan(0)
225
+ expect(mockGetFile).toHaveBeenCalledWith("abc123")
226
+ } finally {
227
+ globalThis.fetch = originalFetch
228
+ }
229
+ })
230
+
231
+ test("returns null when file_path is missing", async () => {
232
+ const mockGetFile = mock(async () => ({
233
+ file_path: undefined,
234
+ file_size: 0,
235
+ }))
236
+
237
+ const result = await downloadTelegramFile({
238
+ fileId: "abc123",
239
+ token: "BOT_TOKEN",
240
+ getFile: mockGetFile,
241
+ filename: "photo.jpg",
242
+ mime: "image/jpeg",
243
+ })
244
+
245
+ expect(result).toBeNull()
246
+ })
247
+
248
+ test("returns null when file_size > 20MB", async () => {
249
+ const mockGetFile = mock(async () => ({
250
+ file_path: "photos/big.jpg",
251
+ file_size: 25 * 1024 * 1024, // 25MB
252
+ }))
253
+
254
+ const result = await downloadTelegramFile({
255
+ fileId: "abc123",
256
+ token: "BOT_TOKEN",
257
+ getFile: mockGetFile,
258
+ filename: "big.jpg",
259
+ mime: "image/jpeg",
260
+ })
261
+
262
+ expect(result).toBeNull()
263
+ })
264
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Media file handling: download from Telegram, convert to data URLs,
3
+ * and build SDK-compatible part arrays.
4
+ */
5
+
6
+ const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB — Telegram Bot API limit
7
+
8
+ const MIME_MAP: Record<string, string> = {
9
+ ".jpg": "image/jpeg",
10
+ ".jpeg": "image/jpeg",
11
+ ".png": "image/png",
12
+ ".gif": "image/gif",
13
+ ".webp": "image/webp",
14
+ ".svg": "image/svg+xml",
15
+ ".pdf": "application/pdf",
16
+ ".zip": "application/zip",
17
+ ".mp3": "audio/mpeg",
18
+ ".ogg": "audio/ogg",
19
+ ".wav": "audio/wav",
20
+ ".mp4": "video/mp4",
21
+ ".webm": "video/webm",
22
+ ".json": "application/json",
23
+ ".xml": "application/xml",
24
+ ".csv": "text/csv",
25
+ ".txt": "text/plain",
26
+ ".md": "text/markdown",
27
+ ".html": "text/html",
28
+ ".css": "text/css",
29
+ ".js": "text/javascript",
30
+ ".ts": "text/typescript",
31
+ ".py": "text/x-python",
32
+ ".rs": "text/x-rust",
33
+ ".go": "text/x-go",
34
+ ".java": "text/x-java",
35
+ ".c": "text/x-c",
36
+ ".cpp": "text/x-c++",
37
+ ".h": "text/x-c",
38
+ ".rb": "text/x-ruby",
39
+ ".sh": "text/x-shellscript",
40
+ ".yaml": "text/yaml",
41
+ ".yml": "text/yaml",
42
+ ".toml": "text/toml",
43
+ }
44
+
45
+ export function bufferToDataUrl(buffer: Buffer, mime: string): string {
46
+ return `data:${mime};base64,${buffer.toString("base64")}`
47
+ }
48
+
49
+ export function buildFilePart(params: {
50
+ buffer: Buffer
51
+ mime: string
52
+ filename: string
53
+ }): { type: "file"; mime: string; url: string; filename: string } {
54
+ return {
55
+ type: "file",
56
+ mime: params.mime,
57
+ url: bufferToDataUrl(params.buffer, params.mime),
58
+ filename: params.filename,
59
+ }
60
+ }
61
+
62
+ export function buildMediaParts(params: {
63
+ buffer: Buffer
64
+ mime: string
65
+ filename: string
66
+ caption?: string
67
+ }): Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename: string }> {
68
+ const filePart = buildFilePart(params)
69
+ if (params.caption && params.caption.trim()) {
70
+ return [{ type: "text", text: params.caption }, filePart]
71
+ }
72
+ return [filePart]
73
+ }
74
+
75
+ export function getMimeFromFileName(filename: string): string {
76
+ const dot = filename.lastIndexOf(".")
77
+ if (dot === -1) return "application/octet-stream"
78
+ const ext = filename.slice(dot).toLowerCase()
79
+ return MIME_MAP[ext] ?? "application/octet-stream"
80
+ }
81
+
82
+ type TelegramMessage = {
83
+ photo?: Array<{ file_id: string; [k: string]: unknown }>
84
+ document?: {
85
+ file_id: string
86
+ file_name?: string
87
+ mime_type?: string
88
+ [k: string]: unknown
89
+ }
90
+ voice?: { file_id: string; [k: string]: unknown }
91
+ audio?: {
92
+ file_id: string
93
+ mime_type?: string
94
+ file_name?: string
95
+ [k: string]: unknown
96
+ }
97
+ video?: {
98
+ file_id: string
99
+ mime_type?: string
100
+ [k: string]: unknown
101
+ }
102
+ [k: string]: unknown
103
+ }
104
+
105
+ export function extractFileRef(
106
+ msg: TelegramMessage,
107
+ ): { fileId: string; filename?: string; mime?: string } | null {
108
+ if (msg.photo && msg.photo.length > 0) {
109
+ const largest = msg.photo[msg.photo.length - 1]!
110
+ return { fileId: largest.file_id, mime: "image/jpeg" }
111
+ }
112
+
113
+ if (msg.document) {
114
+ return {
115
+ fileId: msg.document.file_id,
116
+ filename: msg.document.file_name,
117
+ mime: msg.document.mime_type,
118
+ }
119
+ }
120
+
121
+ if (msg.voice) {
122
+ return { fileId: msg.voice.file_id, mime: "audio/ogg" }
123
+ }
124
+
125
+ if (msg.audio) {
126
+ return {
127
+ fileId: msg.audio.file_id,
128
+ filename: msg.audio.file_name,
129
+ mime: msg.audio.mime_type ?? "audio/mpeg",
130
+ }
131
+ }
132
+
133
+ if (msg.video) {
134
+ return {
135
+ fileId: msg.video.file_id,
136
+ mime: msg.video.mime_type ?? "video/mp4",
137
+ }
138
+ }
139
+
140
+ return null
141
+ }
142
+
143
+ export async function downloadTelegramFile(params: {
144
+ fileId: string
145
+ token: string
146
+ getFile: (fileId: string) => Promise<{ file_path?: string; file_size?: number }>
147
+ filename?: string
148
+ mime?: string
149
+ }): Promise<{ buffer: Buffer; mime: string; filename: string } | null> {
150
+ const file = await params.getFile(params.fileId)
151
+
152
+ if (!file.file_path) return null
153
+ if (file.file_size && file.file_size > MAX_FILE_SIZE) return null
154
+
155
+ const url = `https://api.telegram.org/file/bot${params.token}/${file.file_path}`
156
+ const res = await fetch(url)
157
+ const buffer = Buffer.from(await res.arrayBuffer())
158
+
159
+ const mime =
160
+ params.mime ??
161
+ res.headers.get("content-type") ??
162
+ "application/octet-stream"
163
+
164
+ const filename =
165
+ params.filename ?? file.file_path.split("/").pop() ?? "file"
166
+
167
+ return { buffer, mime, filename }
168
+ }