@xbibzlibrary/telebibz 0.4.4 → 0.4.5

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.
@@ -0,0 +1,243 @@
1
+ # Bekerja dengan file: upload, download, validasi (Bahasa Indonesia)
2
+
3
+ Panduan lengkap setiap alur file di telebibz: mengunduh file yang dikirim user, mengunggah file ke Telegram, mengirim ulang via `file_id`, memvalidasi unggahan, serta batasan dan jebakan Telegram Bot API.
4
+
5
+ ## Daftar isi
6
+
7
+ 1. [Download: satu panggilan dengan `downloadFile()`](#1-download-satu-panggilan-dengan-downloadfile)
8
+ 2. [Download: alur manual dengan `getFile()`](#2-download-alur-manual-dengan-getfile)
9
+ 3. [Penamaan property: `file_path` vs `filePath`](#3-penamaan-property-file_path-vs-filepath)
10
+ 4. [Upload: semua tipe sumber](#4-upload-semua-tipe-sumber)
11
+ 5. [Upload: validasi sebelum mengirim](#5-upload-validasi-sebelum-mengirim)
12
+ 6. [Media group dengan `attach://`](#6-media-group-dengan-attach)
13
+ 7. [Batasan dan masa berlaku](#7-batasan-dan-masa-berlaku)
14
+ 8. [Local Bot API server](#8-local-bot-api-server)
15
+ 9. [Menguji alur file tanpa jaringan](#9-menguji-alur-file-tanpa-jaringan)
16
+ 10. [Troubleshooting](#10-troubleshooting)
17
+
18
+ ## 1. Download: satu panggilan dengan `downloadFile()`
19
+
20
+ `bot.downloadFile()` / `ctx.downloadFile()` me-resolve `file_id` lewat `getFile` lalu mengunduh byte mentahnya dalam satu panggilan:
21
+
22
+ ```ts
23
+ bot.on("message:document", async (ctx) => {
24
+ const fileId = ctx.message.document.file_id;
25
+
26
+ // Unduh ke memori…
27
+ const file = await ctx.downloadFile(fileId);
28
+ console.log(file.fileName, file.sizeBytes, file.url);
29
+ // file.bytes adalah Uint8Array
30
+
31
+ // …atau langsung ke disk
32
+ const saved = await ctx.downloadFile(fileId, { destination: "downloads/report.pdf" });
33
+ console.log(`Tersimpan di ${saved.savedTo}`);
34
+ });
35
+ ```
36
+
37
+ Hasilnya (`DownloadedFile`) membawa semua yang dibutuhkan:
38
+
39
+ | Field | Arti |
40
+ |---|---|
41
+ | `file` | Objek `File` Telegram yang dikembalikan `getFile` |
42
+ | `bytes` | Byte mentah file (`Uint8Array`) |
43
+ | `filePath` | `file_path` yang dipakai untuk unduhan |
44
+ | `url` | URL unduhan langsung — valid **minimal 1 jam** |
45
+ | `fileName` | Segmen path terakhir `filePath` (mis. `report.pdf`) |
46
+ | `sizeBytes` | Panjang byte `bytes` |
47
+ | `savedTo` | Path lokal, terisi saat Anda memberi `destination` |
48
+
49
+ Error-nya presisi:
50
+ - Telegram tidak mengembalikan `file_path` → `TelegramError` dengan `kind: "validation"`
51
+ - Unduhan HTTP-nya sendiri gagal → `TelegramNetworkError` (lengkap dengan status)
52
+ - `getFile` gagal (file_id salah, file terlalu besar) → `TelegramError` asli dari Telegram
53
+
54
+ Keduanya menerima `AbortSignal`:
55
+
56
+ ```ts
57
+ const controller = new AbortController();
58
+ setTimeout(() => controller.abort(), 10_000);
59
+ const file = await bot.downloadFile(fileId, { signal: controller.signal });
60
+ ```
61
+
62
+ ## 2. Download: alur manual dengan `getFile()`
63
+
64
+ Kalau ingin membangun URL sendiri (mis. untuk HTTP client lain):
65
+
66
+ ```ts
67
+ const file = await ctx.getFile(fileId); // objek File Telegram
68
+ if (!file.file_path) throw new Error("File tidak tersedia (di atas 20 MB atau kedaluwarsa)");
69
+
70
+ const url = `https://api.telegram.org/file/bot${process.env.TELEGRAM_BOT_TOKEN}/${file.file_path}`;
71
+ const response = await fetch(url); // ← fetch — JANGAN createReadStream (tidak bisa membuka URL)
72
+ if (!response.ok) throw new Error(`Unduhan gagal: HTTP ${response.status}`);
73
+ const bytes = new Uint8Array(await response.arrayBuffer());
74
+ ```
75
+
76
+ Tiga aturan yang terkandung di snippet ini:
77
+ 1. Prefix URL-nya `/file/bot<TOKEN>/` — **kata `bot` wajib ada**. Lupa menuliskannya adalah kesalahan paling umum dan hasilnya `404 Not Found`.
78
+ 2. `fetch` yang mengunduh byte; `fs.createReadStream()` hanya membuka **path lokal** — diberi URL ia akan crash dengan `ENOENT` pada stream error yang tidak tertangani.
79
+ 3. URL dijamin valid **minimal 1 jam**. Jangan pernah cache lama; panggil `getFile` lagi setelah kedaluwarsa.
80
+
81
+ ## 3. Penamaan property: `file_path` vs `filePath`
82
+
83
+ Ini menjegal semua orang minimal sekali. Dua gaya penamaan ini milik layer yang berbeda:
84
+
85
+ | Penamaan | Milik | Contoh |
86
+ |---|---|---|
87
+ | `snake_case` (`file_path`) | **Objek mentah Telegram** — hasil `getFile()`, `ctx.message.document`, `ctx.message.photo` | `file.file_path`, `document.file_id`, `photo.file_unique_id` |
88
+ | `camelCase` (`filePath`) | **Tipe hasil telebibz** — `DownloadedFile` dan opsi library | `downloaded.filePath`, `downloaded.fileName`, `downloaded.sizeBytes` |
89
+
90
+ ```ts
91
+ const file = await ctx.getFile(fileId);
92
+ file.file_path; // ✅ objek Telegram → snake_case
93
+ file.filePath; // ❌ undefined — itu nama milik DownloadedFile
94
+
95
+ const downloaded = await ctx.downloadFile(fileId);
96
+ downloaded.filePath; // ✅ hasil library → camelCase
97
+ downloaded.file_path; // ❌ undefined
98
+ ```
99
+
100
+ Kalau cek "tidak ada path" Anda gagal padahal response yang di-log jelas memuat `file_path`, berarti Anda membaca property camelCase dari objek snake_case.
101
+
102
+ ## 4. Upload: semua tipe sumber
103
+
104
+ Semua pengirim `replyWith*` dan panggilan API mentah menerima tipe `InputFile`. Kirim unggahan sebagai `{ source, filename? }`:
105
+
106
+ ```ts
107
+ // Dari path di disk (absolut, ./, atau ../)
108
+ await ctx.replyWithDocument({ source: "reports/q3.pdf", filename: "Q3-report.pdf" });
109
+
110
+ // Dari byte mentah
111
+ const bytes = new Uint8Array(await someFile.bytes());
112
+ await ctx.replyWithDocument({ source: bytes, filename: "data.bin" });
113
+
114
+ // Dari Blob atau File (File membawa namanya sendiri)
115
+ await ctx.replyWithDocument({ source: new File([bytes], "photo.png") });
116
+
117
+ // Dari web ReadableStream atau stream Node (dikuras otomatis)
118
+ import { createReadStream } from "node:fs";
119
+ await ctx.replyWithVideo({ source: createReadStream("clip.mp4"), filename: "clip.mp4" });
120
+ ```
121
+
122
+ Catatan:
123
+ - `filename` menimpa nama apa pun yang dimiliki sumber (untuk path, basename dipakai secara default).
124
+ - **Anda tidak pernah merakit `FormData` sendiri.** Transport mendeteksi payload unggahan dan beralih ke multipart otomatis. `FormData` buatan tangan dengan stream Node pasti gagal (`append` butuh `Blob`) — selalu serahkan stream/byte ke library.
125
+ - Nilai telanjang juga bisa: `await ctx.replyWithDocument(bytes)` (tanpa nama) atau file id Telegram yang sudah ada:
126
+
127
+ ```ts
128
+ // Kirim ulang via file_id — tanpa unduh, tanpa upload, tanpa batas ukuran
129
+ await ctx.replyWithDocument(ctx.message.document.file_id);
130
+ ```
131
+
132
+ ## 5. Upload: validasi sebelum mengirim
133
+
134
+ `validateUpload()` / `assertValidUpload()` menegakkan aturan Anda sendiri sebelum satu byte pun keluar dari proses:
135
+
136
+ ```ts
137
+ import { assertValidUpload, UploadValidationError } from "@xbibzlibrary/telebibz";
138
+
139
+ bot.command("doc", async (ctx) => {
140
+ const filePath = ctx.message?.text?.split(/\s+/)[1];
141
+ if (!filePath) return void (await ctx.reply("Penggunaan: /doc <path>"));
142
+
143
+ const info = await stat(filePath);
144
+ try {
145
+ assertValidUpload(
146
+ { sizeBytes: info.size, fileName: filePath },
147
+ {
148
+ maxBytes: 50 * 1024 * 1024, // batas dokumen Telegram
149
+ allowedExtensions: [".pdf", ".docx", ".pptx"], // case-insensitive
150
+ },
151
+ );
152
+ } catch (error) {
153
+ if (error instanceof UploadValidationError) {
154
+ return void (await ctx.reply(`❌ ${error.message}`)); // mencantumkan semua pelanggaran
155
+ }
156
+ throw error;
157
+ }
158
+
159
+ await ctx.replyWithDocument({ source: filePath });
160
+ });
161
+ ```
162
+
163
+ `validateUpload()` mengembalikan daftar issue alih-alih melempar (array kosong = valid). Aturan MIME mendukung wildcard:
164
+
165
+ ```ts
166
+ validateUpload({ mimeType: "image/png" }, { allowedMimeTypes: ["image/*"] }); // []
167
+ ```
168
+
169
+ Aturan yang tersedia: `maxBytes`, `allowedMimeTypes` (persis atau wildcard `image/*`), `allowedExtensions` (titik opsional, case-insensitive).
170
+
171
+ ## 6. Media group dengan `attach://`
172
+
173
+ `sendMediaGroup` menerima array JSON input media; file biner ikut sebagai **bagian form terpisah** yang direferensikan lewat `attach://<nama>`:
174
+
175
+ ```ts
176
+ await ctx.replyWithMediaGroup([
177
+ { type: "photo", media: "attach://pic1" },
178
+ { type: "photo", media: "attach://pic2" },
179
+ ], { pic1: bytes1, pic2: bytes2 } as never);
180
+ ```
181
+
182
+ Transport mendeteksi bagian biner dan beralih ke multipart otomatis; array `media` sendiri diserialisasi sebagai satu field JSON — persis kontrak Telegram.
183
+
184
+ ## 7. Batasan dan masa berlaku
185
+
186
+ | Batasan | Nilai | Catatan |
187
+ |---|---|---|
188
+ | Unduhan via `getFile` | **20 MB** | File lebih besar: `getFile` gagal (HTTP 400 "file is too big") — bukan `file_path` kosong |
189
+ | Upload foto | 10 MB | |
190
+ | Upload file lain | 50 MB | |
191
+ | Kirim ulang via `file_id` | **Tanpa batas** | File sudah ada di Telegram |
192
+ | Kirim via URL | 5 MB foto / 20 MB lainnya | Telegram yang mengambil URL-nya |
193
+ | Validitas URL unduhan | **≥ 1 jam** | Jalankan `getFile` lagi setelah kedaluwarsa |
194
+ | Kehadiran `file_path` | Opsional di skema | Selalu cek sebelum dipakai |
195
+
196
+ Batas upload milik Telegram, bukan library — `validateUpload()` adalah cara Anda menolak lebih awal dengan pesan yang ramah.
197
+
198
+ ## 8. Local Bot API server
199
+
200
+ Menjalankan [local Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server) sendiri menghapus batas unduhan 20 MB dan mengizinkan upload hingga 2000 MB:
201
+
202
+ ```ts
203
+ const bot = new Bot({
204
+ token: process.env.TELEGRAM_BOT_TOKEN!,
205
+ apiBaseUrl: "http://localhost:8081", // opsi Bot API
206
+ transportOptions: { timeoutMs: 600_000 },
207
+ });
208
+ ```
209
+
210
+ `downloadFile()` dan `fileUrl()` memetakan `/bot<token>` ke `/file/bot<token>` di base URL mana pun, jadi unduhan juga bekerja lewat server lokal. Catatan: pada server lokal, `file_path` berupa **path absolut di disk server** — fetch URL-nya hanya bila server jarak jauh; baca path-nya langsung bila bot berjalan di mesin yang sama.
211
+
212
+ ## 9. Menguji alur file tanpa jaringan
213
+
214
+ `MockTransport` (dari `@xbibzlibrary/telebibz/testing`) mengimplementasikan member download:
215
+
216
+ ```ts
217
+ import { createTestBot } from "@xbibzlibrary/telebibz/testing";
218
+
219
+ const { bot, transport } = createTestBot();
220
+ transport.respond("getFile", { ok: true, result: { file_id: "F1", file_unique_id: "U1", file_path: "documents/a.pdf" } });
221
+ transport.downloadBytes = new TextEncoder().encode("pdf-content");
222
+
223
+ const file = await bot.downloadFile("F1");
224
+ file.fileName; // "a.pdf"
225
+ new TextDecoder().decode(file.bytes); // "pdf-content"
226
+ transport.downloads; // ["documents/a.pdf"] — unduhan yang tercatat
227
+ ```
228
+
229
+ Lihat [TESTING.id.md](TESTING.id.md) untuk panduan testing lengkap.
230
+
231
+ ## 10. Troubleshooting
232
+
233
+ | Gejala | Penyebab | Perbaikan |
234
+ |---|---|---|
235
+ | "Gagal mendapatkan path file" padahal response yang di-log memuat `file_path` | Membaca `file.filePath`/`file.path` dari objek mentah Telegram | Pakai `file.file_path` (snake_case) — atau lewati manual sepenuhnya dengan `ctx.downloadFile()` |
236
+ | `ENOENT … open 'https://…'` pada ReadStream | `createReadStream()` hanya membuka path lokal | Pakai `fetch(url)` untuk URL, atau `ctx.downloadFile()` |
237
+ | URL unduhan mengembalikan 404 | Prefix `bot` hilang di `/file/bot<TOKEN>/` | Pakai field `url` dari `downloadFile()` — selalu dibangun dengan benar |
238
+ | `getFile` mengembalikan HTTP 400 "file is too big" | File di atas 20 MB | Pakai local Bot API server, atau kirim ulang via `file_id` |
239
+ | `FormData append: parameter 2 is not of type 'Blob'` | `FormData` buatan tangan berisi stream Node | Serahkan `{ source: stream, filename }` ke `replyWith*`; library yang mengurus multipart |
240
+ | `file_path` tadinya ada, sekarang hilang | URL kedaluwarsa (>1 jam) | Panggil `getFile` lagi |
241
+ | File terunduh 0 byte / salah | `file_id` milik bot lain | `file_id` bersifat per-bot; pakai id dari update bot Anda sendiri |
242
+
243
+ English: [FILES.md](FILES.md) · 简体中文: [FILES.zh-CN.md](FILES.zh-CN.md)
package/docs/FILES.md ADDED
@@ -0,0 +1,243 @@
1
+ # Working with files: upload, download, validation (English)
2
+
3
+ Complete guide to every file flow in telebibz: downloading files users send you, uploading files to Telegram, sending by `file_id`, validating uploads, and the limits and gotchas that come with the Telegram Bot API.
4
+
5
+ ## Contents
6
+
7
+ 1. [Download: one call with `downloadFile()`](#1-download-one-call-with-downloadfile)
8
+ 2. [Download: manual flow with `getFile()`](#2-download-manual-flow-with-getfile)
9
+ 3. [Property naming: `file_path` vs `filePath`](#3-property-naming-file_path-vs-filepath)
10
+ 4. [Upload: every source type](#4-upload-every-source-type)
11
+ 5. [Upload: validation before sending](#5-upload-validation-before-sending)
12
+ 6. [Media groups with `attach://`](#6-media-groups-with-attach)
13
+ 7. [Limits and lifetimes](#7-limits-and-lifetimes)
14
+ 8. [Local Bot API server](#8-local-bot-api-server)
15
+ 9. [Testing file flows without network](#9-testing-file-flows-without-network)
16
+ 10. [Troubleshooting](#10-troubleshooting)
17
+
18
+ ## 1. Download: one call with `downloadFile()`
19
+
20
+ `bot.downloadFile()` / `ctx.downloadFile()` resolves the `file_id` through `getFile` and downloads the raw bytes in a single call:
21
+
22
+ ```ts
23
+ bot.on("message:document", async (ctx) => {
24
+ const fileId = ctx.message.document.file_id;
25
+
26
+ // Downloads to memory…
27
+ const file = await ctx.downloadFile(fileId);
28
+ console.log(file.fileName, file.sizeBytes, file.url);
29
+ // file.bytes is a Uint8Array
30
+
31
+ // …or straight to disk
32
+ const saved = await ctx.downloadFile(fileId, { destination: "downloads/report.pdf" });
33
+ console.log(`Saved to ${saved.savedTo}`);
34
+ });
35
+ ```
36
+
37
+ The result (`DownloadedFile`) carries everything:
38
+
39
+ | Field | Meaning |
40
+ |---|---|
41
+ | `file` | The Telegram `File` object returned by `getFile` |
42
+ | `bytes` | Raw file bytes (`Uint8Array`) |
43
+ | `filePath` | The `file_path` used for the download |
44
+ | `url` | Direct download URL — valid for **at least 1 hour** |
45
+ | `fileName` | Last path segment of `filePath` (e.g. `report.pdf`) |
46
+ | `sizeBytes` | Byte length of `bytes` |
47
+ | `savedTo` | Local path, set when you passed `destination` |
48
+
49
+ Errors are precise:
50
+ - Telegram returns no `file_path` → `TelegramError` with `kind: "validation"`
51
+ - The HTTP download itself fails → `TelegramNetworkError` (with status)
52
+ - `getFile` fails (bad `file_id`, file too big) → the original `TelegramError` from Telegram
53
+
54
+ Both variants accept an `AbortSignal`:
55
+
56
+ ```ts
57
+ const controller = new AbortController();
58
+ setTimeout(() => controller.abort(), 10_000);
59
+ const file = await bot.downloadFile(fileId, { signal: controller.signal });
60
+ ```
61
+
62
+ ## 2. Download: manual flow with `getFile()`
63
+
64
+ If you want to build the URL yourself (e.g. to hand it to another HTTP client):
65
+
66
+ ```ts
67
+ const file = await ctx.getFile(fileId); // Telegram File object
68
+ if (!file.file_path) throw new Error("File unavailable (over 20 MB or expired)");
69
+
70
+ const url = `https://api.telegram.org/file/bot${process.env.TELEGRAM_BOT_TOKEN}/${file.file_path}`;
71
+ const response = await fetch(url); // fetch — NEVER createReadStream (it cannot open URLs)
72
+ if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
73
+ const bytes = new Uint8Array(await response.arrayBuffer());
74
+ ```
75
+
76
+ Three rules this snippet encodes:
77
+ 1. The URL prefix is `/file/bot<TOKEN>/` — **the word `bot` is required**. Forgetting it is the single most common mistake and yields `404 Not Found`.
78
+ 2. `fetch` downloads the bytes; `fs.createReadStream()` only opens **local paths** — passing it a URL crashes the process with `ENOENT` on an unhandled stream error.
79
+ 3. The URL is guaranteed valid for **at least 1 hour**. Never cache it long-term; call `getFile` again when it expires.
80
+
81
+ ## 3. Property naming: `file_path` vs `filePath`
82
+
83
+ This trips everyone once. The two naming styles belong to different layers:
84
+
85
+ | Naming | Belongs to | Examples |
86
+ |---|---|---|
87
+ | `snake_case` (`file_path`) | **Raw Telegram objects** — the result of `getFile()`, `ctx.message.document`, `ctx.message.photo` | `file.file_path`, `document.file_id`, `photo.file_unique_id` |
88
+ | `camelCase` (`filePath`) | **telebibz result types** — `DownloadedFile` and library options | `downloaded.filePath`, `downloaded.fileName`, `downloaded.sizeBytes` |
89
+
90
+ ```ts
91
+ const file = await ctx.getFile(fileId);
92
+ file.file_path; // ✅ Telegram object → snake_case
93
+ file.filePath; // ❌ undefined — this is the DownloadedFile name
94
+
95
+ const downloaded = await ctx.downloadFile(fileId);
96
+ downloaded.filePath; // ✅ library result → camelCase
97
+ downloaded.file_path; // ❌ undefined
98
+ ```
99
+
100
+ If your "no file path" check fails while the logged response clearly contains `file_path`, you are reading a camelCase property from a snake_case object.
101
+
102
+ ## 4. Upload: every source type
103
+
104
+ All `replyWith*` senders and raw API calls accept the `InputFile` type. Pass uploads as `{ source, filename? }`:
105
+
106
+ ```ts
107
+ // From a path on disk (absolute, ./, or ../)
108
+ await ctx.replyWithDocument({ source: "reports/q3.pdf", filename: "Q3-report.pdf" });
109
+
110
+ // From raw bytes
111
+ const bytes = new Uint8Array(await someFile.bytes());
112
+ await ctx.replyWithDocument({ source: bytes, filename: "data.bin" });
113
+
114
+ // From a Blob or File (File carries its own name)
115
+ await ctx.replyWithDocument({ source: new File([bytes], "photo.png") });
116
+
117
+ // From a web ReadableStream or a Node.js stream (drained automatically)
118
+ import { createReadStream } from "node:fs";
119
+ await ctx.replyWithVideo({ source: createReadStream("clip.mp4"), filename: "clip.mp4" });
120
+ ```
121
+
122
+ Notes:
123
+ - `filename` overrides whatever name the source would otherwise have (for paths, the basename is used by default).
124
+ - **You never build `FormData` yourself.** The transport detects upload payloads and switches to multipart automatically. Hand-rolled `FormData` with Node streams fails (`append` requires a `Blob`) — always pass the stream/bytes to the library instead.
125
+ - Bare values also work: `await ctx.replyWithDocument(bytes)` (no filename) or an existing Telegram file id:
126
+
127
+ ```ts
128
+ // Re-send by file_id — no download, no upload, no size limit
129
+ await ctx.replyWithDocument(ctx.message.document.file_id);
130
+ ```
131
+
132
+ ## 5. Upload: validation before sending
133
+
134
+ `validateUpload()` / `assertValidUpload()` enforce your own rules before a byte leaves the process:
135
+
136
+ ```ts
137
+ import { assertValidUpload, UploadValidationError } from "@xbibzlibrary/telebibz";
138
+
139
+ bot.command("doc", async (ctx) => {
140
+ const filePath = ctx.message?.text?.split(/\s+/)[1];
141
+ if (!filePath) return void (await ctx.reply("Usage: /doc <path>"));
142
+
143
+ const info = await stat(filePath);
144
+ try {
145
+ assertValidUpload(
146
+ { sizeBytes: info.size, fileName: filePath },
147
+ {
148
+ maxBytes: 50 * 1024 * 1024, // Telegram document cap
149
+ allowedExtensions: [".pdf", ".docx", ".pptx"], // case-insensitive
150
+ },
151
+ );
152
+ } catch (error) {
153
+ if (error instanceof UploadValidationError) {
154
+ return void (await ctx.reply(`❌ ${error.message}`)); // lists every violation
155
+ }
156
+ throw error;
157
+ }
158
+
159
+ await ctx.replyWithDocument({ source: filePath });
160
+ });
161
+ ```
162
+
163
+ `validateUpload()` returns issues instead of throwing (empty array = valid). MIME rules support wildcards:
164
+
165
+ ```ts
166
+ validateUpload({ mimeType: "image/png" }, { allowedMimeTypes: ["image/*"] }); // []
167
+ ```
168
+
169
+ Available rules: `maxBytes`, `allowedMimeTypes` (exact or `image/*` wildcards), `allowedExtensions` (dot optional, case-insensitive).
170
+
171
+ ## 6. Media groups with `attach://`
172
+
173
+ `sendMediaGroup` takes a JSON array of input media; binary files ride along as **separate form parts** referenced by `attach://<name>`:
174
+
175
+ ```ts
176
+ await ctx.replyWithMediaGroup([
177
+ { type: "photo", media: "attach://pic1" },
178
+ { type: "photo", media: "attach://pic2" },
179
+ ], { pic1: bytes1, pic2: bytes2 } as never);
180
+ ```
181
+
182
+ The transport detects the binary parts and switches to multipart automatically; the `media` array itself is serialized as one JSON form field, exactly as Telegram requires.
183
+
184
+ ## 7. Limits and lifetimes
185
+
186
+ | Limit | Value | Notes |
187
+ |---|---|---|
188
+ | Download via `getFile` | **20 MB** | Larger files: `getFile` fails (HTTP 400 "file is too big") — not an empty `file_path` |
189
+ | Upload photos | 10 MB | |
190
+ | Upload other files | 50 MB | |
191
+ | Re-send by `file_id` | **No limit** | Telegram already has the file |
192
+ | Send by URL | 5 MB photos / 20 MB other | Telegram fetches the URL itself |
193
+ | Download URL validity | **≥ 1 hour** | Re-run `getFile` after expiry |
194
+ | `file_path` presence | Optional in the schema | Always check before using it |
195
+
196
+ Upload limits are Telegram's, not the library's — `validateUpload()` is how you reject early with a friendly message.
197
+
198
+ ## 8. Local Bot API server
199
+
200
+ Running your own [local Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server) removes the 20 MB download cap and allows 2000 MB uploads:
201
+
202
+ ```ts
203
+ const bot = new Bot({
204
+ token: process.env.TELEGRAM_BOT_TOKEN!,
205
+ apiBaseUrl: "http://localhost:8081", // Bot API option
206
+ transportOptions: { timeoutMs: 600_000 },
207
+ });
208
+ ```
209
+
210
+ `downloadFile()` and `fileUrl()` map `/bot<token>` to `/file/bot<token>` on any base URL, so downloads work against local servers too. Note: with a local server, `file_path` is an **absolute path on the server's disk** — fetch the URL only if the server is remote; read the path directly when your bot runs on the same machine.
211
+
212
+ ## 9. Testing file flows without network
213
+
214
+ `MockTransport` (from `@xbibzlibrary/telebibz/testing`) implements the download members:
215
+
216
+ ```ts
217
+ import { createTestBot } from "@xbibzlibrary/telebibz/testing";
218
+
219
+ const { bot, transport } = createTestBot();
220
+ transport.respond("getFile", { ok: true, result: { file_id: "F1", file_unique_id: "U1", file_path: "documents/a.pdf" } });
221
+ transport.downloadBytes = new TextEncoder().encode("pdf-content");
222
+
223
+ const file = await bot.downloadFile("F1");
224
+ file.fileName; // "a.pdf"
225
+ new TextDecoder().decode(file.bytes); // "pdf-content"
226
+ transport.downloads; // ["documents/a.pdf"] — the recorded download
227
+ ```
228
+
229
+ See [TESTING.md](TESTING.md) for the full testing guide.
230
+
231
+ ## 10. Troubleshooting
232
+
233
+ | Symptom | Cause | Fix |
234
+ |---|---|---|
235
+ | "Gagal mendapatkan path file" while the logged response contains `file_path` | Reading `file.filePath`/`file.path` from a raw Telegram object | Use `file.file_path` (snake_case) — or skip manual handling entirely with `ctx.downloadFile()` |
236
+ | `ENOENT … open 'https://…'` on a ReadStream | `createReadStream()` only opens local paths | Use `fetch(url)` for URLs, or `ctx.downloadFile()` |
237
+ | Download URL returns 404 | Missing `bot` prefix in `/file/bot<TOKEN>/` | Use the `url` field from `downloadFile()` — it is always built correctly |
238
+ | `getFile` returns HTTP 400 "file is too big" | File over 20 MB | Use a local Bot API server, or re-send by `file_id` |
239
+ | `FormData append: parameter 2 is not of type 'Blob'` | Hand-rolled `FormData` with a Node stream | Pass `{ source: stream, filename }` to `replyWith*` instead; the library handles multipart |
240
+ | `file_path` was there, now it's gone | URL expired (>1 hour) | Call `getFile` again |
241
+ | Downloaded file is 0 bytes / wrong | `file_id` belongs to another bot | `file_id`s are bot-scoped; use the id from your own bot's updates |
242
+
243
+ Bahasa Indonesia: [FILES.id.md](FILES.id.md) · 简体中文: [FILES.zh-CN.md](FILES.zh-CN.md)