@xbibzlibrary/telebibz 0.4.2 → 0.4.4

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 (49) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.id.md +6 -3
  3. package/README.md +6 -3
  4. package/README.zh-CN.md +6 -3
  5. package/dist/src/api/client.d.ts +28 -0
  6. package/dist/src/api/client.d.ts.map +1 -1
  7. package/dist/src/api/client.js +28 -0
  8. package/dist/src/api/client.js.map +1 -1
  9. package/dist/src/api/transport.d.ts +14 -0
  10. package/dist/src/api/transport.d.ts.map +1 -1
  11. package/dist/src/api/transport.js +67 -2
  12. package/dist/src/api/transport.js.map +1 -1
  13. package/dist/src/api/types.d.ts +25 -0
  14. package/dist/src/api/types.d.ts.map +1 -1
  15. package/dist/src/context/context.d.ts +13 -3
  16. package/dist/src/context/context.d.ts.map +1 -1
  17. package/dist/src/context/context.js +15 -0
  18. package/dist/src/context/context.js.map +1 -1
  19. package/dist/src/core/bot.d.ts +12 -0
  20. package/dist/src/core/bot.d.ts.map +1 -1
  21. package/dist/src/core/bot.js +16 -0
  22. package/dist/src/core/bot.js.map +1 -1
  23. package/dist/src/index.d.ts +1 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +1 -0
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/testing.d.ts +6 -0
  28. package/dist/src/testing.d.ts.map +1 -1
  29. package/dist/src/testing.js +6 -0
  30. package/dist/src/testing.js.map +1 -1
  31. package/dist/src/utils/files.d.ts +45 -0
  32. package/dist/src/utils/files.d.ts.map +1 -0
  33. package/dist/src/utils/files.js +53 -0
  34. package/dist/src/utils/files.js.map +1 -0
  35. package/dist-cjs/src/api/client.js +28 -0
  36. package/dist-cjs/src/api/transport.js +67 -2
  37. package/dist-cjs/src/context/context.js +15 -0
  38. package/dist-cjs/src/core/bot.js +16 -0
  39. package/dist-cjs/src/index.js +1 -0
  40. package/dist-cjs/src/testing.js +6 -0
  41. package/dist-cjs/src/utils/files.js +58 -0
  42. package/docs/API.id.md +70 -0
  43. package/docs/API.md +70 -0
  44. package/docs/API.zh-CN.md +70 -0
  45. package/docs/STORAGE.id.md +105 -0
  46. package/docs/STORAGE.md +105 -0
  47. package/docs/STORAGE.zh-CN.md +105 -0
  48. package/examples/files.ts +35 -0
  49. package/package.json +1 -1
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FetchTransport = void 0;
4
4
  const promises_1 = require("node:fs/promises");
5
5
  const node_path_1 = require("node:path");
6
+ const consumers_1 = require("node:stream/consumers");
6
7
  const errors_js_1 = require("./errors.js");
7
8
  class FetchTransport {
8
9
  baseUrl;
@@ -46,6 +47,37 @@ class FetchTransport {
46
47
  const retryAfterMs = Math.max(0, (data.parameters?.retry_after ?? 0) * 1000);
47
48
  this.floodUntil = Math.max(this.floodUntil, Date.now() + retryAfterMs);
48
49
  }
50
+ /** Direct-download URL for a `file_path` from `getFile` (`/bot<token>` → `/file/bot<token>`). */
51
+ fileUrl(filePath) {
52
+ const downloadBase = /\/bot[^/]+$/.test(this.baseUrl) ? this.baseUrl.replace(/\/bot([^/]+)$/, "/file/bot$1") : this.baseUrl;
53
+ return `${downloadBase}/${filePath}`;
54
+ }
55
+ /** Downloads the raw bytes behind a `file_path` from `getFile` (Telegram caps downloads at 20 MB). */
56
+ async download(filePath, signal) {
57
+ const controller = new AbortController();
58
+ const timeout = setTimeout(() => controller.abort(new Error("File download timed out")), Math.max(this.timeoutMs, 120_000));
59
+ const onAbort = () => controller.abort(signal?.reason);
60
+ signal?.addEventListener("abort", onAbort, { once: true });
61
+ try {
62
+ const response = await this.fetchImpl(this.fileUrl(filePath), { method: "GET", signal: controller.signal });
63
+ if (!response.ok) {
64
+ const bodyText = await response.text().catch(() => "");
65
+ throw new errors_js_1.TelegramNetworkError(`Failed to download ${filePath}: HTTP ${response.status} ${response.statusText || "request failed"}`, { method: "getFile", payload: { file_path: filePath }, status: response.status, cause: bodyText.slice(0, 512) });
66
+ }
67
+ return new Uint8Array(await response.arrayBuffer());
68
+ }
69
+ catch (error) {
70
+ if (error instanceof errors_js_1.TelegramNetworkError)
71
+ throw error;
72
+ if (signal?.aborted)
73
+ throw error;
74
+ throw new errors_js_1.TelegramNetworkError(`Failed to download ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { method: "getFile", payload: { file_path: filePath }, cause: error });
75
+ }
76
+ finally {
77
+ clearTimeout(timeout);
78
+ signal?.removeEventListener("abort", onAbort);
79
+ }
80
+ }
49
81
  async request({ method, payload = {}, signal, timeoutMs }) {
50
82
  const effectiveTimeoutMs = timeoutMs ?? this.timeoutMs;
51
83
  const hasUpload = await containsUpload(payload);
@@ -149,10 +181,20 @@ function isRetryableNetworkError(error) {
149
181
  async function containsUpload(value) {
150
182
  if (value instanceof Uint8Array || value instanceof ArrayBuffer || value instanceof Blob)
151
183
  return true;
184
+ if (value instanceof ReadableStream)
185
+ return true;
152
186
  if (typeof value === "string")
153
187
  return false;
154
- if (value && typeof value === "object" && "source" in value)
155
- return containsUpload(value.source);
188
+ if (value && typeof value === "object" && "source" in value) {
189
+ const source = value.source;
190
+ // A path-like string source means the transport reads the file from disk
191
+ // and must switch to multipart — a plain string source is just a file_id.
192
+ if (typeof source === "string" && ((0, node_path_1.isAbsolute)(source) || source.startsWith("./") || source.startsWith("../")))
193
+ return true;
194
+ return containsUpload(source);
195
+ }
196
+ if (typeof value?.pipe === "function")
197
+ return true;
156
198
  if (Array.isArray(value)) {
157
199
  for (const item of value)
158
200
  if (await containsUpload(item))
@@ -165,6 +207,13 @@ async function containsUpload(value) {
165
207
  }
166
208
  return false;
167
209
  }
210
+ /** Converts a Node.js or web ReadableStream to a Blob by draining it. */
211
+ async function streamToBlob(stream) {
212
+ if (stream instanceof ReadableStream)
213
+ return new Blob([await new Response(stream).arrayBuffer()]);
214
+ const bytes = await (0, consumers_1.buffer)(stream);
215
+ return new Blob([bytes]);
216
+ }
168
217
  async function appendFormValue(form, key, value) {
169
218
  if (value === undefined || value === null)
170
219
  return;
@@ -180,6 +229,10 @@ async function appendFormValue(form, key, value) {
180
229
  form.append(key, value);
181
230
  return;
182
231
  }
232
+ if (value instanceof ReadableStream || typeof value?.pipe === "function") {
233
+ form.append(key, await streamToBlob(value));
234
+ return;
235
+ }
183
236
  if (value && typeof value === "object" && "source" in value) {
184
237
  const upload = value;
185
238
  if (typeof upload.source === "string" && ((0, node_path_1.isAbsolute)(upload.source) || upload.source.startsWith("./") || upload.source.startsWith("../"))) {
@@ -188,6 +241,18 @@ async function appendFormValue(form, key, value) {
188
241
  form.append(key, new Blob([bytes]), upload.filename ?? (0, node_path_1.basename)(filePath));
189
242
  return;
190
243
  }
244
+ if (upload.filename !== undefined && upload.source instanceof Uint8Array) {
245
+ form.append(key, new Blob([upload.source]), upload.filename);
246
+ return;
247
+ }
248
+ if (upload.filename !== undefined && upload.source instanceof Blob) {
249
+ form.append(key, upload.source, upload.filename);
250
+ return;
251
+ }
252
+ if (upload.filename !== undefined && upload.source instanceof ArrayBuffer) {
253
+ form.append(key, new Blob([upload.source]), upload.filename);
254
+ return;
255
+ }
191
256
  return appendFormValue(form, key, upload.source);
192
257
  }
193
258
  if (typeof value === "string") {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Context = void 0;
4
+ const promises_1 = require("node:fs/promises");
4
5
  class Context {
5
6
  update;
6
7
  api;
@@ -178,6 +179,20 @@ class Context {
178
179
  async getUserProfilePhotos(userId = this.from?.id, extra = {}) { if (!userId)
179
180
  throw new Error("No user in this update."); return this.api.call("getUserProfilePhotos", { user_id: userId, ...extra }); }
180
181
  async getFile(fileId) { return this.api.methods.getFile({ file_id: fileId }); }
182
+ /**
183
+ * Resolves `fileId` with `getFile` and downloads the raw bytes (see
184
+ * `Bot.downloadFile`). Pass `destination` to persist the bytes to a local
185
+ * file path. Prefer this over reading `file_path` manually — it throws
186
+ * precise `TelegramError`s when Telegram returns no path.
187
+ */
188
+ async downloadFile(fileId, options = {}) {
189
+ const downloaded = await this.api.downloadFile(fileId, options.signal !== undefined ? { signal: options.signal } : {});
190
+ if (options.destination !== undefined) {
191
+ await (0, promises_1.writeFile)(options.destination, downloaded.bytes);
192
+ return { ...downloaded, savedTo: options.destination };
193
+ }
194
+ return downloaded;
195
+ }
181
196
  // ─────────────────────────────────────────────────────────────────────────────
182
197
  // Admin & moderation (full Telegraf-parity surface; chat defaults to ctx.chat)
183
198
  // ─────────────────────────────────────────────────────────────────────────────
@@ -16,6 +16,7 @@ const broadcast_js_1 = require("../broadcast/broadcast.js");
16
16
  const concurrency_js_1 = require("../utils/concurrency.js");
17
17
  const webhook_reply_js_1 = require("./webhook-reply.js");
18
18
  const node_async_hooks_1 = require("node:async_hooks");
19
+ const promises_1 = require("node:fs/promises");
19
20
  /** Thrown when a single update exceeds `handlerTimeout`; the handler keeps running in the background. */
20
21
  class UpdateTimeoutError extends Error {
21
22
  name = "UpdateTimeoutError";
@@ -264,6 +265,21 @@ class Bot {
264
265
  async getMe() { const me = await this.api.methods.getMe(); this.me = me; return me; }
265
266
  async setCommands(commands, scope, languageCode) { return this.api.call("setMyCommands", { commands, scope, language_code: languageCode }); }
266
267
  async deleteCommands(scope, languageCode) { return this.api.call("deleteMyCommands", { scope, language_code: languageCode }); }
268
+ /**
269
+ * Downloads a Telegram file by `file_id`: resolves it with `getFile`, then
270
+ * fetches the raw bytes via the transport's download endpoint. Pass
271
+ * `destination` to also persist the bytes to a local file path. The
272
+ * returned `url` is valid for at least one hour; Telegram caps downloads
273
+ * at 20 MB.
274
+ */
275
+ async downloadFile(fileId, options = {}) {
276
+ const downloaded = await this.api.downloadFile(fileId, options.signal !== undefined ? { signal: options.signal } : {});
277
+ if (options.destination !== undefined) {
278
+ await (0, promises_1.writeFile)(options.destination, downloaded.bytes);
279
+ return { ...downloaded, savedTo: options.destination };
280
+ }
281
+ return downloaded;
282
+ }
267
283
  /**
268
284
  * Handles a single update. Updates for different chats run in parallel;
269
285
  * updates for the same chat are processed strictly in arrival order so
@@ -29,6 +29,7 @@ __exportStar(require("./queue/queue.js"), exports);
29
29
  __exportStar(require("./plugins/plugin.js"), exports);
30
30
  __exportStar(require("./webhook/handler.js"), exports);
31
31
  __exportStar(require("./utils/text.js"), exports);
32
+ __exportStar(require("./utils/files.js"), exports);
32
33
  __exportStar(require("./utils/concurrency.js"), exports);
33
34
  __exportStar(require("./broadcast/broadcast.js"), exports);
34
35
  __exportStar(require("./state/conversation.js"), exports);
@@ -9,9 +9,15 @@ const bot_js_1 = require("./core/bot.js");
9
9
  const context_js_1 = require("./context/context.js");
10
10
  class MockTransport {
11
11
  calls = [];
12
+ /** Downloads recorded through `download()` (the `file_path` values, in order). */
13
+ downloads = [];
14
+ /** Bytes returned by `download()`; defaults to the UTF-8 encoding of the file path. */
15
+ downloadBytes;
12
16
  responses = new Map();
13
17
  respond(method, response) { this.responses.set(method, response); return this; }
14
18
  async request(request) { this.calls.push(request); const configured = this.responses.get(request.method); const data = typeof configured === "function" ? configured(request.payload) : configured ?? { ok: true, result: true }; return { status: data.ok ? 200 : (data.error_code ?? 500), headers: new Headers(), data: data }; }
19
+ fileUrl(filePath) { return `mock://files/${filePath}`; }
20
+ async download(filePath) { this.downloads.push(filePath); return this.downloadBytes ?? new TextEncoder().encode(filePath); }
15
21
  }
16
22
  exports.MockTransport = MockTransport;
17
23
  function createMockUpdate(overrides = {}) { return { update_id: 1, message: { message_id: 1, date: Date.now(), chat: { id: 1, type: "private" }, from: { id: 2, is_bot: false, first_name: "Test" }, text: "/start" }, ...overrides }; }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ /**
3
+ * File-upload validation helpers. Telegram caps uploads at 50 MB for most
4
+ * methods (10 MB for photos) and rejects mismatches between content and
5
+ * declared type; these helpers let applications enforce their own limits
6
+ * before a byte leaves the process.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.UploadValidationError = void 0;
10
+ exports.validateUpload = validateUpload;
11
+ exports.assertValidUpload = assertValidUpload;
12
+ /** Thrown by {@link assertValidUpload} when an upload violates the rules. */
13
+ class UploadValidationError extends Error {
14
+ name = "UploadValidationError";
15
+ issues;
16
+ constructor(issues) {
17
+ super(`Upload rejected: ${issues.map((issue) => issue.message).join("; ")}`);
18
+ this.issues = issues;
19
+ }
20
+ }
21
+ exports.UploadValidationError = UploadValidationError;
22
+ function normalizeExtension(extension) {
23
+ const value = extension.trim().toLowerCase();
24
+ return value.startsWith(".") ? value.slice(1) : value;
25
+ }
26
+ function mimeMatches(pattern, mimeType) {
27
+ const normalizedPattern = pattern.trim().toLowerCase();
28
+ if (normalizedPattern.endsWith("/*"))
29
+ return mimeType.toLowerCase().startsWith(normalizedPattern.slice(0, -1));
30
+ return mimeType.toLowerCase() === normalizedPattern;
31
+ }
32
+ /**
33
+ * Validates an upload against the rules and returns every violation found
34
+ * (an empty array means the upload is acceptable).
35
+ */
36
+ function validateUpload(upload, rules) {
37
+ const issues = [];
38
+ if (rules.maxBytes !== undefined && upload.sizeBytes !== undefined && upload.sizeBytes > rules.maxBytes) {
39
+ issues.push({ code: "too_large", field: "sizeBytes", message: `size ${upload.sizeBytes} bytes exceeds the ${rules.maxBytes} byte limit`, actual: upload.sizeBytes, limit: rules.maxBytes });
40
+ }
41
+ if (rules.allowedMimeTypes !== undefined && rules.allowedMimeTypes.length > 0 && upload.mimeType !== undefined && !rules.allowedMimeTypes.some((pattern) => mimeMatches(pattern, upload.mimeType))) {
42
+ issues.push({ code: "mime_not_allowed", field: "mimeType", message: `MIME type ${upload.mimeType} is not allowed (allowed: ${rules.allowedMimeTypes.join(", ")})`, actual: upload.mimeType, limit: rules.allowedMimeTypes.join(", ") });
43
+ }
44
+ if (rules.allowedExtensions !== undefined && rules.allowedExtensions.length > 0 && upload.fileName !== undefined) {
45
+ const extension = upload.fileName.includes(".") ? upload.fileName.slice(upload.fileName.lastIndexOf(".") + 1) : "";
46
+ const normalized = normalizeExtension(extension);
47
+ if (!normalized || !rules.allowedExtensions.some((allowed) => normalizeExtension(allowed) === normalized)) {
48
+ issues.push({ code: "extension_not_allowed", field: "fileName", message: `extension .${normalized || "?"} is not allowed (allowed: ${rules.allowedExtensions.join(", ")})`, actual: upload.fileName, limit: rules.allowedExtensions.join(", ") });
49
+ }
50
+ }
51
+ return issues;
52
+ }
53
+ /** Like {@link validateUpload} but throws {@link UploadValidationError} when any rule is violated. */
54
+ function assertValidUpload(upload, rules) {
55
+ const issues = validateUpload(upload, rules);
56
+ if (issues.length > 0)
57
+ throw new UploadValidationError(issues);
58
+ }
package/docs/API.id.md CHANGED
@@ -274,6 +274,22 @@ deleteCommands(
274
274
 
275
275
  Jalan pintas ke `deleteMyCommands`.
276
276
 
277
+ ### `bot.downloadFile(fileId, options?)`
278
+
279
+ ```ts
280
+ downloadFile(
281
+ fileId: string,
282
+ options?: { signal?: AbortSignal; destination?: string },
283
+ ): Promise<DownloadedFile>
284
+ ```
285
+
286
+ Me-resolve `fileId` lewat `getFile`, lalu mengunduh byte mentahnya melalui endpoint download transport. Berikan `destination` untuk juga menyimpan byte ke path file lokal (`savedTo` terisi pada hasil). Melempar `TelegramError` (kind `validation`) saat Telegram tidak mengembalikan `file_path` atau transport tidak bisa mengunduh, dan `TelegramNetworkError` saat unduhan gagal. Telegram membatasi unduhan pada 20 MB; `url` hasilnya tetap valid minimal satu jam.
287
+
288
+ ```ts
289
+ const file = await bot.downloadFile(photoFileId, { destination: "downloads/photo.jpg" });
290
+ console.log(file.fileName, file.sizeBytes, file.url, file.savedTo);
291
+ ```
292
+
277
293
  ### `bot.handleUpdate(update)`
278
294
 
279
295
  ```ts
@@ -593,6 +609,37 @@ raw(
593
609
 
594
610
  Memanggil method string sembarang di transport. Gunakan ini untuk method Telegram atau parameter baru yang belum masuk `TelegramMethodMap`. Response `ok: false` tetap diubah menjadi `TelegramError`.
595
611
 
612
+ ### `api.downloadFile(fileId, options?)`
613
+
614
+ ```ts
615
+ downloadFile(fileId: string, options?: { signal?: AbortSignal }): Promise<DownloadedFile>
616
+ ```
617
+
618
+ Inti `bot.downloadFile` di level API client: me-resolve `getFile`, memvalidasi `file_path` tersedia, lalu mengunduh byte melalui transport.
619
+
620
+ ### `DownloadedFile`
621
+
622
+ ```ts
623
+ interface DownloadedFile {
624
+ file: File; // objek File Telegram dari getFile
625
+ bytes: Uint8Array; // byte mentah file (maks 20 MB sesuai Telegram)
626
+ filePath: string; // file_path yang dipakai untuk unduhan
627
+ url: string; // URL unduhan langsung, valid minimal satu jam
628
+ fileName: string; // segmen path terakhir dari filePath
629
+ sizeBytes: number; // panjang byte
630
+ savedTo?: string; // terisi saat Bot.downloadFile menyimpan file ke disk
631
+ }
632
+ ```
633
+
634
+ ### `fetchTransport.fileUrl(filePath)` dan `fetchTransport.download(filePath, signal?)`
635
+
636
+ ```ts
637
+ fileUrl(filePath: string): string
638
+ download(filePath: string, signal?: AbortSignal): Promise<Uint8Array>
639
+ ```
640
+
641
+ `FetchTransport` memetakan base URL `/bot<token>` ke endpoint download `/file/bot<token>`; `download` melakukan GET byte (batas bawah timeout 120 detik untuk file besar) dan melempar `TelegramNetworkError` saat HTTP gagal. Keduanya member opsional pada interface `Transport`, jadi transport kustom boleh menghilangkannya — `downloadFile` lalu gagal dengan error validasi yang jelas, bukan crash.
642
+
596
643
  ### Parameter dan hasil bertipe yang tersedia
597
644
 
598
645
  Tipe berikut dipetakan khusus pada rilis ini.
@@ -1498,6 +1545,27 @@ template("Halo {{ user.name }}", { user: { name: "Ayu" } });
1498
1545
 
1499
1546
  ---
1500
1547
 
1548
+ ### `validateUpload(upload, rules)`
1549
+
1550
+ ```ts
1551
+ validateUpload(upload: UploadLike, rules: UploadRules): UploadValidationIssue[]
1552
+ ```
1553
+
1554
+ Memvalidasi unggahan sebelum dikirim: `maxBytes` (batas ukuran), `allowedMimeTypes` (persis atau wildcard seperti `image/*`), dan `allowedExtensions` (case-insensitive, dengan atau tanpa titik awal). Mengembalikan semua pelanggaran yang ditemukan — array kosong berarti unggahan diterima.
1555
+
1556
+ ### `assertValidUpload(upload, rules)`
1557
+
1558
+ Aturan yang sama, tetapi melempar `UploadValidationError` (dengan seluruh `issues` terlampir) alih-alih mengembalikannya.
1559
+
1560
+ ```ts
1561
+ import { assertValidUpload } from "@xbibzlibrary/telebibz";
1562
+
1563
+ assertValidUpload(
1564
+ { sizeBytes: fileBytes.length, mimeType: "image/png", fileName: "logo.png" },
1565
+ { maxBytes: 5_000_000, allowedMimeTypes: ["image/png", "image/jpeg"], allowedExtensions: [".png", ".jpg"] },
1566
+ );
1567
+ ```
1568
+
1501
1569
  ## 14. Testing utilities
1502
1570
 
1503
1571
  Import dari `@xbibzlibrary/telebibz/testing` atau root package.
@@ -1524,6 +1592,8 @@ const transport = new MockTransport()
1524
1592
  });
1525
1593
  ```
1526
1594
 
1595
+ `MockTransport` juga mengimplementasikan member download opsional: `download(filePath)` mencatat path ke `downloads` dan mengembalikan `downloadBytes` (default: encoding UTF-8 dari path), serta `fileUrl(filePath)` mengembalikan `mock://files/<filePath>` — sehingga `bot.downloadFile()` sepenuhnya bisa dites tanpa jaringan.
1596
+
1527
1597
  ### `createMockUpdate(overrides?)`
1528
1598
 
1529
1599
  ```ts
package/docs/API.md CHANGED
@@ -291,6 +291,22 @@ deleteCommands(
291
291
 
292
292
  Shortcut to `deleteMyCommands`.
293
293
 
294
+ ### `bot.downloadFile(fileId, options?)`
295
+
296
+ ```ts
297
+ downloadFile(
298
+ fileId: string,
299
+ options?: { signal?: AbortSignal; destination?: string },
300
+ ): Promise<DownloadedFile>
301
+ ```
302
+
303
+ Resolves `fileId` through `getFile`, then downloads the raw bytes via the transport's download endpoint. Pass `destination` to also persist the bytes to a local file path (`savedTo` is set on the result). Throws a `TelegramError` (kind `validation`) when Telegram returns no `file_path` or the transport cannot download, and a `TelegramNetworkError` when the download fails. Telegram caps downloads at 20 MB; the returned `url` stays valid for at least one hour.
304
+
305
+ ```ts
306
+ const file = await bot.downloadFile(photoFileId, { destination: "downloads/photo.jpg" });
307
+ console.log(file.fileName, file.sizeBytes, file.url, file.savedTo);
308
+ ```
309
+
294
310
  ### `bot.handleUpdate(update)`
295
311
 
296
312
  ```ts
@@ -610,6 +626,37 @@ raw(
610
626
 
611
627
  Calls an arbitrary method string on the transport. Use this for Telegram methods or new parameters not yet included in `TelegramMethodMap`. Responses with `ok: false` are still converted into a `TelegramError`.
612
628
 
629
+ ### `api.downloadFile(fileId, options?)`
630
+
631
+ ```ts
632
+ downloadFile(fileId: string, options?: { signal?: AbortSignal }): Promise<DownloadedFile>
633
+ ```
634
+
635
+ The API-client core of `bot.downloadFile`: resolves `getFile`, validates that a `file_path` came back, and downloads the bytes through the transport.
636
+
637
+ ### `DownloadedFile`
638
+
639
+ ```ts
640
+ interface DownloadedFile {
641
+ file: File; // Telegram File object from getFile
642
+ bytes: Uint8Array; // raw file bytes (max 20 MB per Telegram)
643
+ filePath: string; // file_path used for the download
644
+ url: string; // direct download URL, valid for at least one hour
645
+ fileName: string; // last path segment of filePath
646
+ sizeBytes: number; // byte length of bytes
647
+ savedTo?: string; // set when Bot.downloadFile persisted the file to disk
648
+ }
649
+ ```
650
+
651
+ ### `fetchTransport.fileUrl(filePath)` and `fetchTransport.download(filePath, signal?)`
652
+
653
+ ```ts
654
+ fileUrl(filePath: string): string
655
+ download(filePath: string, signal?: AbortSignal): Promise<Uint8Array>
656
+ ```
657
+
658
+ `FetchTransport` maps its `/bot<token>` base URL to the `/file/bot<token>` download endpoint; `download` GETs the bytes (timeout floor of 120 s for large files) and throws `TelegramNetworkError` on HTTP failure. Both are optional members of the `Transport` interface, so custom transports may omit them — `downloadFile` then fails with a precise validation error instead of crashing.
659
+
613
660
  ### Available typed parameters and results
614
661
 
615
662
  The following types are specially mapped in this release.
@@ -1532,6 +1579,27 @@ template("Halo {{ user.name }}", { user: { name: "Ayu" } });
1532
1579
 
1533
1580
  ---
1534
1581
 
1582
+ ### `validateUpload(upload, rules)`
1583
+
1584
+ ```ts
1585
+ validateUpload(upload: UploadLike, rules: UploadRules): UploadValidationIssue[]
1586
+ ```
1587
+
1588
+ Validates an upload before it is sent: `maxBytes` (size limit), `allowedMimeTypes` (exact or wildcard like `image/*`), and `allowedExtensions` (case-insensitive, with or without the leading dot). Returns every violation found — an empty array means the upload is acceptable.
1589
+
1590
+ ### `assertValidUpload(upload, rules)`
1591
+
1592
+ Same rules, but throws `UploadValidationError` (with all `issues` attached) instead of returning them.
1593
+
1594
+ ```ts
1595
+ import { assertValidUpload } from "@xbibzlibrary/telebibz";
1596
+
1597
+ assertValidUpload(
1598
+ { sizeBytes: fileBytes.length, mimeType: "image/png", fileName: "logo.png" },
1599
+ { maxBytes: 5_000_000, allowedMimeTypes: ["image/png", "image/jpeg"], allowedExtensions: [".png", ".jpg"] },
1600
+ );
1601
+ ```
1602
+
1535
1603
  ## 14. Testing utilities
1536
1604
 
1537
1605
  Import dari `@xbibzlibrary/telebibz/testing` atau root package.
@@ -1558,6 +1626,8 @@ const transport = new MockTransport()
1558
1626
  });
1559
1627
  ```
1560
1628
 
1629
+ `MockTransport` also implements the optional download members: `download(filePath)` records the path into `downloads` and returns `downloadBytes` (default: the UTF-8 encoding of the path), and `fileUrl(filePath)` returns `mock://files/<filePath>` — so `bot.downloadFile()` is fully testable without network access.
1630
+
1561
1631
  ### `createMockUpdate(overrides?)`
1562
1632
 
1563
1633
  ```ts
package/docs/API.zh-CN.md CHANGED
@@ -274,6 +274,22 @@ deleteCommands(
274
274
 
275
275
  相当于 `deleteMyCommands` 的快捷方式。
276
276
 
277
+ ### `bot.downloadFile(fileId, options?)`
278
+
279
+ ```ts
280
+ downloadFile(
281
+ fileId: string,
282
+ options?: { signal?: AbortSignal; destination?: string },
283
+ ): Promise<DownloadedFile>
284
+ ```
285
+
286
+ 通过 `getFile` 解析 `fileId`,再经由 transport 的下载端点获取原始字节。传入 `destination` 可同时把字节保存到本地文件路径(结果中的 `savedTo` 会被填充)。当 Telegram 未返回 `file_path` 或 transport 不支持下载时抛出 `TelegramError`(kind 为 `validation`);下载失败时抛出 `TelegramNetworkError`。Telegram 限制单次下载 20 MB;返回的 `url` 至少一小时内有效。
287
+
288
+ ```ts
289
+ const file = await bot.downloadFile(photoFileId, { destination: "downloads/photo.jpg" });
290
+ console.log(file.fileName, file.sizeBytes, file.url, file.savedTo);
291
+ ```
292
+
277
293
  ### `bot.handleUpdate(update)`
278
294
 
279
295
  ```ts
@@ -593,6 +609,37 @@ raw(
593
609
 
594
610
  在 transport 上调用任意字符串方法。用于调用尚未包含在 `TelegramMethodMap` 的新的 Telegram 方法或参数。即使响应为 `ok: false`,也会被转换为 `TelegramError`。
595
611
 
612
+ ### `api.downloadFile(fileId, options?)`
613
+
614
+ ```ts
615
+ downloadFile(fileId: string, options?: { signal?: AbortSignal }): Promise<DownloadedFile>
616
+ ```
617
+
618
+ `bot.downloadFile` 的 API 客户端核心:解析 `getFile`、校验返回了 `file_path`,然后通过 transport 下载字节。
619
+
620
+ ### `DownloadedFile`
621
+
622
+ ```ts
623
+ interface DownloadedFile {
624
+ file: File; // getFile 返回的 Telegram File 对象
625
+ bytes: Uint8Array; // 原始字节(Telegram 上限 20 MB)
626
+ filePath: string; // 用于下载的 file_path
627
+ url: string; // 直接下载 URL,至少一小时内有效
628
+ fileName: string; // filePath 的最后一段
629
+ sizeBytes: number; // bytes 的字节长度
630
+ savedTo?: string; // 当 Bot.downloadFile 把文件写入磁盘时填充
631
+ }
632
+ ```
633
+
634
+ ### `fetchTransport.fileUrl(filePath)` 与 `fetchTransport.download(filePath, signal?)`
635
+
636
+ ```ts
637
+ fileUrl(filePath: string): string
638
+ download(filePath: string, signal?: AbortSignal): Promise<Uint8Array>
639
+ ```
640
+
641
+ `FetchTransport` 把 `/bot<token>` 基础 URL 映射为 `/file/bot<token>` 下载端点;`download` 以 GET 获取字节(大文件的 timeout 下限为 120 秒),HTTP 失败时抛出 `TelegramNetworkError`。两者都是 `Transport` 接口的可选成员,自定义 transport 可以省略 —— 此时 `downloadFile` 会以明确的校验错误失败,而不是崩溃。
642
+
596
643
  ### 可用的类型化参数和返回值
597
644
 
598
645
  以下类型在此发布版本中已被映射:
@@ -1492,6 +1539,27 @@ template("你好 {{ user.name }}", { user: { name: "Ayu" } });
1492
1539
 
1493
1540
  ---
1494
1541
 
1542
+ ### `validateUpload(upload, rules)`
1543
+
1544
+ ```ts
1545
+ validateUpload(upload: UploadLike, rules: UploadRules): UploadValidationIssue[]
1546
+ ```
1547
+
1548
+ 在发送前校验上传:`maxBytes`(大小上限)、`allowedMimeTypes`(精确或通配符如 `image/*`)、`allowedExtensions`(大小写不敏感,带不带前导点均可)。返回找到的全部违规 —— 空数组表示上传可接受。
1549
+
1550
+ ### `assertValidUpload(upload, rules)`
1551
+
1552
+ 规则相同,但不返回结果而是抛出 `UploadValidationError`(附带全部 `issues`)。
1553
+
1554
+ ```ts
1555
+ import { assertValidUpload } from "@xbibzlibrary/telebibz";
1556
+
1557
+ assertValidUpload(
1558
+ { sizeBytes: fileBytes.length, mimeType: "image/png", fileName: "logo.png" },
1559
+ { maxBytes: 5_000_000, allowedMimeTypes: ["image/png", "image/jpeg"], allowedExtensions: [".png", ".jpg"] },
1560
+ );
1561
+ ```
1562
+
1495
1563
  ## 14. Testing utilities
1496
1564
 
1497
1565
  Import dari `@xbibzlibrary/telebibz/testing` atau root package.
@@ -1518,6 +1586,8 @@ const transport = new MockTransport()
1518
1586
  });
1519
1587
  ```
1520
1588
 
1589
+ `MockTransport` 同样实现了可选的下载成员:`download(filePath)` 把路径记录进 `downloads` 并返回 `downloadBytes`(默认为路径的 UTF-8 编码),`fileUrl(filePath)` 返回 `mock://files/<filePath>` —— 因此 `bot.downloadFile()` 无需网络即可完整测试。
1590
+
1521
1591
  ### `createMockUpdate(overrides?)`
1522
1592
 
1523
1593
  ```ts
@@ -0,0 +1,105 @@
1
+ # Panduan cepat storage (Bahasa Indonesia)
2
+
3
+ telebibz menyediakan interface `Storage<K, V>` generik dengan lima adapter. Core package **tanpa runtime dependency**: adapter Redis, SQL, dan Mongo menerima driver interface kecil yang sudah Anda punya, jadi Anda yang memilih driver dan versinya.
4
+
5
+ Semua adapter memakai kontrak yang sama — `get` / `set` / `delete` / `has` / `clear` / `keys()` / `entries()` — ditambah **`update(key, updater, { ttlMs })`** yang menyalin penulisan per key sehingga update bersamaan ke key yang sama tidak pernah saling menimpa. TTL diatur per penulisan lewat `{ ttlMs }`.
6
+
7
+ ## MemoryStorage (default — tanpa konfigurasi)
8
+
9
+ ```ts
10
+ import { Bot } from "@xbibzlibrary/telebibz";
11
+
12
+ const bot = new Bot({ token: process.env.TELEGRAM_BOT_TOKEN! });
13
+ // bot.session secara default adalah MemoryStorage<string, S>.
14
+ ```
15
+
16
+ ## JsonFileStorage (persistensi satu file, tetap tanpa dependency)
17
+
18
+ ```ts
19
+ import { Bot, JsonFileStorage } from "@xbibzlibrary/telebibz";
20
+
21
+ const bot = new Bot({
22
+ token: process.env.TELEGRAM_BOT_TOKEN!,
23
+ session: new JsonFileStorage("state/sessions.json"),
24
+ });
25
+ ```
26
+
27
+ ## RedisStorage (bawa client Anda sendiri)
28
+
29
+ Adapter ini hanya butuh lima method callback-style yang dimiliki setiap client Redis — `node-redis` langsung cocok:
30
+
31
+ ```ts
32
+ import { Bot, RedisStorage } from "@xbibzlibrary/telebibz";
33
+ import { createClient } from "redis"; // driver dan versi pilihan Anda
34
+
35
+ const redis = createClient({ url: process.env.REDIS_URL });
36
+ await redis.connect();
37
+
38
+ const bot = new Bot({
39
+ token: process.env.TELEGRAM_BOT_TOKEN!,
40
+ session: new RedisStorage(redis, "mybot:"), // prefix untuk key Anda
41
+ });
42
+ // TTL per penulisan: await bot.session.set(key, value, { ttlMs: 24 * 60 * 60 * 1000 });
43
+ // (kedaluwarsa PX Redis diterapkan otomatis.)
44
+ ```
45
+
46
+ ## SqlStorage (semua database SQL)
47
+
48
+ Implementasikan driver lima method di atas library SQL Anda; contoh ini memakai `better-sqlite3`:
49
+
50
+ ```ts
51
+ import { Bot, SqlStorage } from "@xbibzlibrary/telebibz";
52
+ import Database from "better-sqlite3";
53
+
54
+ const db = new Database("state/bot.db");
55
+ db.exec("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)");
56
+
57
+ const storage = new SqlStorage({
58
+ async get(key) {
59
+ const row = db.prepare("SELECT value, expires_at FROM kv WHERE key = ?").get(key) as { value: string; expires_at: number | null } | undefined;
60
+ return row === undefined ? undefined : JSON.parse(row.value);
61
+ },
62
+ async set(key, value, expiresAt) {
63
+ db.prepare("INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at")
64
+ .run(key, JSON.stringify(value), expiresAt ?? null);
65
+ },
66
+ async delete(key) { return db.prepare("DELETE FROM kv WHERE key = ?").run(key).changes > 0; },
67
+ async has(key) { return db.prepare("SELECT 1 FROM kv WHERE key = ?").get(key) !== undefined; },
68
+ async clear() { db.prepare("DELETE FROM kv").run(); },
69
+ async entries() {
70
+ const rows = db.prepare("SELECT key, value, expires_at FROM kv").all() as Array<{ key: string; value: string; expires_at: number | null }>;
71
+ return rows.map((row) => [row.key, JSON.parse(row.value), row.expiresAt ?? undefined] as [string, unknown, number | undefined]);
72
+ },
73
+ });
74
+
75
+ const bot = new Bot({ token: process.env.TELEGRAM_BOT_TOKEN!, session: storage });
76
+ ```
77
+
78
+ ## MongoStorage (bawa collection Anda sendiri)
79
+
80
+ Adapter ini berbicara langsung dengan bentuk collection MongoDB standar — cukup kirim collection Anda:
81
+
82
+ ```ts
83
+ import { Bot, MongoStorage } from "@xbibzlibrary/telebibz";
84
+ import { MongoClient } from "mongodb";
85
+
86
+ const client = new MongoClient(process.env.MONGODB_URL!);
87
+ await client.connect();
88
+
89
+ const bot = new Bot({
90
+ token: process.env.TELEGRAM_BOT_TOKEN!,
91
+ session: new MongoStorage(client.db("mybot").collection("sessions")),
92
+ });
93
+ ```
94
+
95
+ ## Memilih
96
+
97
+ | Adapter | Pakai saat | Persistensi | Dependency tambahan |
98
+ |---|---|---|---|
99
+ | `MemoryStorage` | bot satu proses, test | selama proses hidup | tidak ada |
100
+ | `JsonFileStorage` | bot kecil, deploy sederhana | file di disk | tidak ada |
101
+ | `RedisStorage` | multi-instance, state bersama | Redis | client Redis Anda |
102
+ | `SqlStorage` | aplikasi berbasis SQL | semua database SQL | driver SQL Anda |
103
+ | `MongoStorage` | stack Mongo yang sudah ada | MongoDB | driver Mongo Anda |
104
+
105
+ Signature API lengkap: [API.id.md](API.id.md). English: [STORAGE.md](STORAGE.md) · 简体中文: [STORAGE.zh-CN.md](STORAGE.zh-CN.md).