media & files

one uniform way to point at a file — local path, url, in-memory buffer, stream, inline text, or an existing Telegram file_id — and the Api layer picks the right wire form, at any nesting depth. plus the read side: downloading, limits, and where local Bot API changes things.

the six sources

a MediaSource is a small discriminated, branded object. you never build one by hand — use the media.* helpers:

media.ts
import { media } from "@yaebal/core";

declare const bytes: Uint8Array;
declare const readable: ReadableStream<Uint8Array>;

media.path("./photo.jpg");             // local file → uploaded
media.url("https://yaebal.mom/p.png"); // remote url → passed as a string
media.buffer(bytes, "p.png");          // in-memory bytes → uploaded
media.stream(readable, "video.mp4");   // web stream / async iterable → uploaded
media.text("hello", "notes.txt");      // string → uploaded as a text file
media.fileId("AgACAgIAAx");            // already on Telegram → reused
helperkindon the wire
media.pathpathread from disk, uploaded as multipart
media.bufferbufferbytes uploaded as multipart (with optional filename)
media.streamstreamweb ReadableStream or async iterable, buffered right before the request
media.texttextstring uploaded as a text file (default name text.txt)
media.urlurlsent as a plain url string
media.fileIdfileIdsent as the file_id string
name your uploads. media.buffer/media.stream default to a bare "file" filename when you don't pass one — always give one with a real extension (media.buffer(bytes, "report.pdf")); Telegram infers the content type from it.

isMediaSource

every helper brands its result with a unique symbol. isMediaSource checks that brand, so a plain object that merely looks like one is rejected — the Api layer uses this to decide what to encode.

guard.ts
import { isMediaSource, media } from "@yaebal/core";

isMediaSource(media.fileId("AgAC"));            // true — branded with a symbol
isMediaSource({ kind: "fileId", fileId: "x" }); // false — not branded

sending media

ctx.sendPhoto and ctx.sendDocument accept a MediaSource or a raw file_id/url string directly. extra params (caption, reply markup, …) go in the second argument.

handler.ts
import { Bot, media } from "@yaebal/core";

const bot = new Bot(process.env.BOT_TOKEN!);

bot.command("photo", (ctx) =>
  ctx.sendPhoto(media.url("https://picsum.photos/400"), {
    caption: "a random picture",
  }),
);

bot.command("doc", (ctx) =>
  ctx.sendDocument(media.path("./report.pdf")),
);

// a raw file_id or url string works too — no wrapper required:
bot.command("cached", (ctx) => ctx.sendPhoto("AgACAgIAAx"));

caption accepts a plain string or a fmt/md/html result, exactly like send's own text argument:

caption-fmt.ts
import { Bot, media } from "@yaebal/core";
import { md } from "@yaebal/fmt";

const bot = new Bot(process.env.BOT_TOKEN!);

// caption accepts a plain string OR a fmt/md/html result, exactly like send()'s text
bot.command("photo", (ctx) =>
  ctx.sendPhoto(media.url("https://picsum.photos/400"), {
    caption: md`**shipped** \`v1.2\``,
  }),
);

albums and nested media

the encoder walks the params — a MediaSource nested inside media[] items, thumbnail/cover fields, sticker sets or story content is rewritten to an attach:// reference automatically. the generated types accept InputFile | string exactly where the runtime handles it. (this uses bot.api.call directly — sendMediaGroup and friends aren't ctx shortcuts on the base context; see contexts for the richer, per-update API where they are.)

album.ts
import { Bot, media } from "@yaebal/core";

const bot = new Bot(process.env.BOT_TOKEN!);
const thumb = new Uint8Array([1, 2, 3]);

// nested media works everywhere the Bot API takes it — sendMediaGroup,
// editMessageMedia, sendPaidMedia, createNewStickerSet, stories, …
await bot.api.call("sendMediaGroup", {
  chat_id: 1,
  media: [
    { type: "photo", media: media.path("./one.jpg"), caption: "first" },
    { type: "photo", media: media.fileId("AgAC") },
    {
      type: "video",
      media: media.url("https://example.com/v.mp4"),
      thumbnail: media.buffer(thumb, "t.jpg"),
    },
  ],
});

how upload works

encodeRequest decides the encoding per request. if no uploadable source is present, the body is JSON and any url/fileId media is inlined to its string:

api.ts
import { encodeRequest, media } from "@yaebal/core";

// no uploadable media anywhere → JSON, with url/fileId inlined to strings
await encodeRequest({ chat_id: 1, photo: media.fileId("AgAC") });
//   { body: '{"chat_id":1,"photo":"AgAC"}', contentType: "application/json" }

await encodeRequest({ photo: media.url("https://yaebal.mom/p.png") });
//   { body: '{"photo":"https://yaebal.mom/p.png"}', contentType: "application/json" }

the moment an uploadable source (path, buffer, stream, text) appears anywhere in the params, the whole request switches to multipart/form-data. each upload is written to a generated field and the param points at it with attach://:

multipart.ts
import { encodeRequest, media } from "@yaebal/core";

// an uploadable source present — at any depth — → the whole request
// becomes multipart. each upload is attached under a generated field
// and referenced via attach://
await encodeRequest({
  chat_id: 7,
  media: [{ type: "photo", media: media.buffer(new Uint8Array([1, 2, 3]), "pic.png") }],
});
// FormData:
//   media   = '[{"type":"photo","media":"attach://_file0"}]'
//   _file0  = <Blob "pic.png">
//   chat_id = "7"
runtime note. media.path() needs a filesystem — the yaebal meta package injects one automatically on node/bun/deno; on edge runtimes pass media.buffer()/media.url() instead, or pass your own readFile to new Bot(token, { readFile }) from bare @yaebal/core. media.stream() is buffered before sending: multipart needs a sized body.

telegram's limits

directionlimit
sending a photo10 MB
sending anything else (document, video, …)50 MB
downloading any file, via the public Bot API20 MB
either direction, via a local bot api server2 GB
hit the 20 MB download cap often? that's exactly what a local bot api server lifts — see local bot api.

downloading

bot.api.downloadFile(fileId) is getFile + a fetch of the result, in one call — no runtime-specific filesystem module needed, so it works the same on node, bun, deno and edge. it throws if Telegram reports no file_path (the file exceeds the 20 MB download cap above) or the download itself fails.

download.ts
import { Bot } from "@yaebal/core";
import { writeFile } from "node:fs/promises";

const bot = new Bot(process.env.BOT_TOKEN!);

bot.on("message:photo", async (ctx) => {
  const fileId = ctx.message.photo.at(-1)?.file_id; // largest size last
  if (!fileId) return;

  // getFile(fileId) + a fetch of the result, one call — no runtime-specific fs needed
  const { filePath, bytes } = await bot.api.downloadFile(fileId);
  await writeFile(`./downloads/${filePath.split("/").pop()}`, bytes);
  await ctx.reply("saved");
});

need just the URL (say, to hand to something else that fetches it)? fileUrl builds it from a file_path you already have:

file-url.ts
import { Bot } from "@yaebal/core";

const bot = new Bot(process.env.BOT_TOKEN!);

bot.on("message:document", async (ctx) => {
  const file = await bot.api.getFile({ file_id: ctx.message.document.file_id });
  if (!file.file_path) return; // file exceeds the 20MB download cap — see the table above

  const url = bot.api.fileUrl(file.file_path);
  // https://api.telegram.org/file/bot<token>/<file_path>
  //                                  ^^^^^^^ contains the bot token — never log it, never
  //                                  hand it to an untrusted client
  void url;
});
contains the token. the file download URL embeds the bot token. never log it, and never hand it to an untrusted client — anyone with it controls the bot.

for a higher-level read side — metadata, links, streaming downloads, save-to-disk helpers, local Bot API server strategies — see @yaebal/files. to look inside a file_id (datacenter, access hash, dedupe key) without any api call, use @yaebal/file-id.

try it — media and poll
import { createBot } from "yaebal";

const bot = createBot(process.env.BOT_TOKEN!);

bot.command("launch", async (ctx) => {
  await ctx.sendPhoto("https://picsum.photos/seed/yaebal/640/360", {
    caption: "release image by url",
  });

  await ctx.sendPoll("ship today?", ["yes", "hold"]);
});

bot.start();