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:
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| helper | kind | on the wire |
|---|---|---|
media.path | path | read from disk, uploaded as multipart |
media.buffer | buffer | bytes uploaded as multipart (with optional filename) |
media.stream | stream | web ReadableStream or async iterable, buffered right before the request |
media.text | text | string uploaded as a text file (default name text.txt) |
media.url | url | sent as a plain url string |
media.fileId | fileId | sent as the file_id string |
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.
import { isMediaSource, media } from "@yaebal/core";
isMediaSource(media.fileId("AgAC")); // true — branded with a symbol
isMediaSource({ kind: "fileId", fileId: "x" }); // false — not brandedsending 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.
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:
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.)
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:
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://:
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"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
| direction | limit |
|---|---|
| sending a photo | 10 MB |
| sending anything else (document, video, …) | 50 MB |
| downloading any file, via the public Bot API | 20 MB |
| either direction, via a local bot api server | 2 GB |
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.
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:
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;
});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.
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();