@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.
- 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 +6 -3
- 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,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
|
+
}
|