@yaebal/files

inspect, link, stream and download telegram files. adds ctx.files with three entry points — info, url and a lazy download() handle with Response-style readers — plus createFiles(api) for use outside middleware. understands self-hosted Bot API servers (--local).

install

terminal
pnpm add @yaebal/files

registration

call bot.install(files()) once. the plugin adds ctx.files to every subsequent handler in the chain. the control is built once per api client — per-update cost is a single property.

bot.ts
import { Bot } from "@yaebal/core";
import { files } from "@yaebal/files";

const bot = new Bot(token);
bot.install(files());

usage

info / url / download accept a file_id string, any api object with a file_id (Document, Audio, Voice, Sticker, …) or a PhotoSize[] array — the largest size wins.

download-photo.ts
bot.on("message:photo", async (ctx) => {
  // ctx.photo is a size array — the largest size is picked automatically
  const info = await ctx.files.info(ctx.photo);       // getFile metadata, no bytes
  if ((info.file_size ?? 0) > 10 * 1024 * 1024) return ctx.reply("too big");

  await ctx.files.download(ctx.photo).toFile("./last-photo.jpg");
});

the download handle

download() returns a lazy FileDownload: nothing is fetched until you read from it. info() costs one getFile (memoized); the body readers additionally fetch the bytes. like a Response, the body is single-use — read it once, call download() again for another pass. info(), url() and disk-sourced toFile() don't consume it.

handle.ts
const dl = ctx.files.download(ctx.document);

await dl.info();              // File metadata (one getFile, memoized)
await dl.url();               // download URL — ⚠️ embeds the bot token by default
await dl.bytes();             // Uint8Array
await dl.text();              // utf-8 string
await dl.json<Config>();      // parsed JSON
await dl.blob();              // Blob
await dl.stream();            // ReadableStream — pipe huge files, no buffering
await dl.toFile("./a.pdf");   // save to disk, returns the path
const bytes = await dl;       // PromiseLike — awaiting yields Uint8Array

outside middleware

standalone.ts
import { createFiles } from "@yaebal/files";

const files = createFiles(bot.api);

// onStart, cron jobs, workers, scripts — no ctx required
await files.download(fileId).toFile("./backup.bin");

self-hosted bot api server

with --local the server reports absolute disk paths instead of CDN paths. strategy is picked per file (source: "auto"): relative path → classic URL; absolute path → baseUrl rewrite when configured, else a direct disk read (copy-on-disk toFile, zero transfer). force one with source: "url" | "disk" | "rewrite", or pass a function (file) => Promise<Uint8Array>.

local-server.ts
bot.install(
  files({
    local: {
      dir: "/var/lib/telegram-bot-api",  // the server's working dir (default)
      mount: "/data",                    // where that volume is mounted for the bot
      baseUrl: "https://files.my.app",   // serve the dir over HTTP → token-less url()
    },
  }),
);

api

exportkinddescription
filesfunction(options?: FilesOptions) => Plugin — returns the plugin.
createFilesfunction(api, options?) => FilesControl — the same control without middleware.
FilesControlinterfacethe type of ctx.files: info / url / download.
FileDownloadclassthe lazy handle: info, url, arrayBuffer, bytes, blob, text, json, stream, toFile; PromiseLike<Uint8Array>.
FilesErrorclassevery failure, with a machine-readable reason: "bad-input", "no-file-path", "download-failed" (carries status), "no-url", "no-filesystem", "config".
resolveFileIdfunctioncollapse a FileInput (string / object / size array) to its file_id.
FileInput, FilesOptions, FileSource, FileCallOptionstypesinputs, plugin options, strategy union, per-call options (signal).

options

optiondefaultwhat it does
source"auto"byte-fetching strategy: "auto" / "url" / "disk" / "rewrite" / custom function.
local.dir/var/lib/telegram-bot-apiserver working dir — the prefix of absolute file_paths it reports.
local.mount= dirbot-side mount point of the shared volume (dirmount remap before disk reads).
local.baseUrlpublic URL of the working dir — enables token-less links and "rewrite".
fetchglobalThis.fetchfetch override for byte downloads (proxy, instrumentation, tests).

per call: ctx.files.download(file, { signal }) — the AbortSignal reaches both getFile and the byte fetch.

production notes

  • the classic download URL embeds the bot token — don't show it to users or log it. configure local.baseUrl for token-less links.
  • the hosted Bot API caps getFile downloads at 20 MB; a local server lifts that to 2 GB.
  • byte downloads go straight through fetch, not through api hooks — @yaebal/again retries the getFile call, not the transfer itself.
  • prefer stream() / toFile() over bytes() for large files — the buffered readers hold the whole file in memory.

testing

stub getFile with @yaebal/test; inject fetch for the byte transfer.

files.test.ts
const env = createTestEnv(bot);
env.onApi("getFile", { file_id: "DOC", file_unique_id: "U", file_path: "documents/d.pdf" });

// byte downloads are injectable too:
bot.install(files({ fetch: myFakeFetch }));

related