Media
Photos, videos, documents, voice notes — all flow through sendFile and downloadMedia on TelegramClient. Large files chunk transparently, so the same call handles a 200 KB thumbnail and a 2 GB video.
Sending files#
One method, four input shapes. file accepts a path string, a Buffer, a Node ReadStream, or a URL — teleproto figures out the rest.
// Path on disk — teleproto reads and streams it
await client.sendFile("me", {
file: "./report.pdf",
caption: "monthly numbers",
});// Already in memory — pass the Buffer directly
import { readFile } from "node:fs/promises";
const buf = await readFile("./photo.jpg");
await client.sendFile("@some_channel", {
file: buf,
caption: "rendered just now",
});// Node ReadStream — useful for files you don't want fully in RAM
import { createReadStream } from "node:fs";
await client.sendFile("me", {
file: createReadStream("./video.mp4"),
forceDocument: false, // send as a playable video, not a generic file
});// URL — teleproto fetches and forwards
await client.sendFile("me", {
file: "https://example.com/cover.png",
caption: "from the web",
});The full sendFile shape: client.sendFile(entity, { file, caption?, forceDocument?, thumb?, voiceNote?, videoNote?, workers?, progressCallback?, attributes?, mimeType? }). Use forceDocument: true to bypass Telegram's media auto-detection and ship raw bytes; use voiceNote / videoNote for the circular-bubble renders.
// Voice note: tell Telegram to render the waveform UI
await client.sendFile("me", {
file: "./reply.ogg",
voiceNote: true,
mimeType: "audio/ogg",
});Uploading without sending#
When the same file is going to many places — or when you need a raw TL method to consume the handle — uploadFile uploads once and returns an InputFile or InputFileBig you can pass into sendFile repeatedly.
// Upload once, attach the same handle to many messages
const handle = await client.uploadFile({
file: "./giant-report.pdf",
workers: 4,
});
for (const peer of ["@team_alpha", "@team_bravo", "@team_charlie"]) {
await client.sendFile(peer, { file: handle, caption: "Q4 report" });
}Shape: client.uploadFile({ file, workers?, onProgress? }). The returned handle is also valid wherever the TL schema expects an InputFile — handy for messages.UploadMedia, sticker uploads, profile photo changes, and similar raw calls.
Downloading#
downloadMedia takes a message (or a bare media object) and either writes to outputFile or hands you the bytes. outputFile can be a path string or a Node Writable stream.
import { Api } from "teleproto";
// Pull every photo from the last 200 messages of a channel
for await (const message of client.iterMessages("@some_channel", {
limit: 200,
})) {
if (!(message.media instanceof Api.MessageMediaPhoto)) continue;
// outputFile is optional — omit it and you get a Buffer back
await client.downloadMedia(message, {
outputFile: `./photos/${message.id}.jpg`,
});
}Omit outputFile and you'll get a Bufferback instead — useful for piping to S3, hashing, thumbnail generation, anything that doesn't want a file on disk first.
// No outputFile → Buffer in memory
const bytes = await client.downloadMedia(message);
// bytes is Buffer | undefined depending on the media typePass thumb (an index or a thumb size object) to download the preview variant instead of the full asset.
Profile photos#
Profile photos have their own helper because they aren't attached to a message. downloadProfilePhoto returns the raw photo bytes as a Buffer.
// Hi-res variant of the user's avatar
const bytes = await client.downloadProfilePhoto("@durov", { isBig: true });
// Buffer of the raw JPEGPass isBig: true for the full-resolution variant; omit it for the small one used in chat lists.
Big files#
teleproto chunks every transfer transparently — you never split the file yourself. For uploads above roughly 10 MB, bump workers to parallelize the chunk uploads. The default is 1; 4–8 is the sweet spot on a typical connection.
// Parallel upload chunks for anything ~10 MB and up
await client.sendFile("me", {
file: "./4k-render.mov",
workers: 8, // default is 1; 4–8 is a healthy range
});For downloads, maxConcurrentDownloads on TelegramClientParams sets the ceiling on parallel chunk requests across the entire client.
import { TelegramClient } from "teleproto";
// Cap concurrent download chunks across the whole client
const client = new TelegramClient(session, apiId, apiHash, {
connectionRetries: 5,
maxConcurrentDownloads: 6,
});Progress#
Both upload and download accept a progressCallback: (transferred, total) => void. Numbers come through as bigInt-compatible values; coerce with Number() for percentage math (safe up to ~9 PB, which Telegram won't hit).
let lastPrinted = 0;
await client.sendFile("me", {
file: "./archive.zip",
workers: 4,
progressCallback: (transferred, total) => {
const pct = Math.floor((Number(transferred) / Number(total)) * 100);
if (pct === lastPrinted) return;
lastPrinted = pct;
const bar = "#".repeat(pct / 2).padEnd(50, "-");
process.stdout.write(`\r[${bar}] ${pct}%`);
},
});
process.stdout.write("\n");File pool#
Tuning per-call workers gets tedious when you have hundreds of downloads in flight. Set downloadPool on the client and the pool handles concurrency for you.
import { TelegramClient } from "teleproto";
const client = new TelegramClient(session, apiId, apiHash, {
connectionRetries: 5,
downloadPool: {
poolSize: 4, // how many parallel download workers the pool keeps hot
workers: 8, // chunk-level parallelism per worker
},
});downloadPool accepts a Partial<FilePoolOptions> — typically poolSize and workers are the knobs you want. Per-call progressCallback still works on top of the pool; the pool just stops you from oversubscribing the socket.