Skip to content
teleproto

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.

send-path.ts
// Path on disk — teleproto reads and streams it
await client.sendFile("me", {
  file: "./report.pdf",
  caption: "monthly numbers",
});
send-buffer.ts
// 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",
});
send-stream.ts
// 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
});
send-url.ts
// 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.

send-voice.ts
// 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-only.ts
// 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.

download-photos.ts
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.

download-buffer.ts
// No outputFile → Buffer in memory
const bytes = await client.downloadMedia(message);
// bytes is Buffer | undefined depending on the media type

Pass 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.

profile-photo.ts
// Hi-res variant of the user's avatar
const bytes = await client.downloadProfilePhoto("@durov", { isBig: true });
// Buffer of the raw JPEG

Pass 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.

big-upload.ts
// 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.

client-download-cap.ts
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).

progress-bar.ts
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.

file-pool.ts
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.