media

one uniform way to point at a file — local path, url, in-memory buffer, or an existing Telegram file_id — and the Api layer picks the right wire form.

the four 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";

media.path("./photo.jpg");                 // local file → uploaded
media.url("https://example.com/p.png");    // remote url → passed as a string
media.buffer(new Uint8Array([…]), "p.png"); // in-memory bytes → uploaded
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.urlurlsent as a plain url string
media.fileIdfileIdsent as the file_id string

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
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:
ctx.sendPhoto("AgACAgIAAx…");

how upload works

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

api.ts
// no path/buffer present → 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://e/p.png") });
//   { body: '{"photo":"https://e/p.png"}', contentType: "application/json" }

the moment a path or buffer appears anywhere in the top-level 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://:

api.ts
// a path or buffer present → the whole request becomes multipart.
// each upload is attached under a generated field and referenced via attach://
await encodeRequest({
  chat_id: 7,
  photo: media.buffer(new Uint8Array([1, 2, 3]), "pic.png"),
  reply_markup: { inline_keyboard: [] },
});
// FormData:
//   photo        = "attach://_file0"
//   _file0       = <Blob "pic.png">
//   chat_id      = "7"                         (non-string → stringified)
//   reply_markup = '{"inline_keyboard":[]}'    (object → JSON)
top-level only. media is handled at the top level of the params. nested MediaSource (e.g. inside sendMediaGroup's media[]) is rejected loudly rather than silently serialized to garbage — pass media as a top-level param.