@yaebal/media-cache

upload a file once, send its file_id forever after. covers every media send — including sendMediaGroup items and editMessageMedia — and self-heals when telegram rejects a stale id.

install

terminal
pnpm add @yaebal/media-cache

transparent mode

install cache.plugin() on the bot. it registers before/after/onError hooks on the api client: cached sources are swapped to their file_id before the request, fresh uploads are remembered from the response. nothing about your handlers changes — ctx.sendPhoto(media.path(…)) just stops re-uploading.

bot.ts
import { createBot, media } from "yaebal";
import { mediaCache } from "@yaebal/media-cache";

const cache = mediaCache();
const bot = createBot(process.env.BOT_TOKEN!)
  .install(cache.plugin());

bot.command("logo", async (ctx) => {
  // first call: uploads and remembers the returned file_id
  // every call after that: sends the cached file_id — no upload
  await ctx.sendPhoto(media.path("./assets/logo.png"));
});

covered methods: sendPhoto, sendDocument, sendAudio, sendVideo, sendAnimation, sendVoice, sendVideoNote, sendSticker, plus each item of a sendMediaGroup and the media of an editMessageMedia.

api.ts
// lower-level form, useful when you only have an Api instance
cache.attach(bot.api);

what keys what

sourcecache keynote
media.path("./a.png")the pathzero extra I/O on a hit
media.url("https://…")the urltelegram skips re-downloading it
media.buffer(bytes)sha-256 of the bytessame bytes → one upload; changed bytes → new key
media.text("…", "a.txt")sha-256 of the text
media.stream(…)single-shot, passes through uncached
media.fileId("…")already the cached form

raw string params (a bare url or file_id) pass through untouched — only media.* sources are cached.

albums and edits

groups.ts
// each album item caches independently — re-sending the album
// uploads only what changed
await ctx.api.sendMediaGroup({
  chat_id: ctx.chat!.id,
  media: [
    { type: "photo", media: media.path("./a.png"), caption: "first" },
    { type: "video", media: media.path("./b.mp4") },
  ],
});

// editMessageMedia is covered too
await ctx.api.editMessageMedia({
  chat_id: ctx.chat!.id,
  message_id,
  media: { type: "photo", media: media.path("./a.png") },
});

self-healing

a cached file_id can go bad: storage shared with another bot (file_ids are per-bot), a wiped test server, a corrupt entry. when telegram answers 400 wrong file identifier, the entry is evicted and the request retries with the original source — the caller never sees the failure. the retried attempt substitutes nothing, so a second failure can't loop.

manual mode

one method per media kind — photo, document, audio, video, animation, voice, videoNote, sticker — each sending to the update's chat with the context's business/topic routing. manual keys live in their own key: namespace and never collide with transparent-mode keys.

manual.ts
// name the key yourself when it should survive the file
// moving between paths or urls
bot.command("poster", async (ctx) => {
  await cache.photo(ctx, "poster:v1", media.url("https://cdn.example/poster.png"), {
    caption: "cached under the key, not the url",
  });
});

invalidation

invalidate.ts
await cache.invalidate(media.path("./logo.png")); // by source
await cache.invalidate("poster:v1");              // by manual key

await cache.keyFor(media.path("./logo.png"));     // "path:logo.png"
await cache.keyFor(media.buffer(bytes));          // "sha256:…"

no ttl by design: file_ids don't expire, they get rejected — and rejection already self-heals. content-keyed sources (buffers, text) also self-invalidate: new content is a new key.

storage & multi-bot

defaults to in-memory (MemoryStorage, lost on restart). pass any StorageAdapter<string> from @yaebal/sklad to persist. set scope when several bots share one storage — a file_id only works for the bot that uploaded it.

storage.ts
const cache = mediaCache({
  storage: myRedisStorage, // any StorageAdapter<string> from @yaebal/sklad
  scope: "my-bot",         // keys become "my-bot:path:…" — see multi-bot note below
});

observability

metrics.ts
const cache = mediaCache({
  onEvent: (e) => {
    // { type: "hit" | "store", method, key, fileId }
    // { type: "evict", key, reason: "invalidated" | "rejected" }
    metrics.increment(`media_cache_${e.type}`);
  },
});

api

exportkinddescription
mediaCache(options?)functioncreates a MediaCache
MediaCacheinterfacethe object returned by mediaCache()
MediaCacheOptionsinterfacestorage?, scope?, onEvent?
MediaCacheEventtypehit / store / evict observations
CachedSendtypethe manual-mode sender signature

MediaCache methods

methoddescription
plugin()installable form: bot.install(cache.plugin())
attach(api)register the caching hooks on an api client (idempotent)
photo(ctx, key, source, extra?)manual mode — one per media kind, explicit cache key
invalidate(source | key)forget one cached file_id
keyFor(source | key)the storage key a source caches under (undefined = uncacheable)

testing

cache.test.ts
import { createApi, media } from "@yaebal/core";
import { withFetch } from "@yaebal/test";

// the cache lives in api hooks, so test it over a real createApi with a
// scripted fetch — see packages/media-cache/src/index.test.ts for a template
await withFetch(scriptedFetch, async () => {
  const api = createApi("TEST", { readFile: async () => bytes });
  mediaCache().attach(api);
  await api.call("sendPhoto", { chat_id: 1, photo: media.path("./a.png") });
});

notes

  • two concurrent first sends of the same source both upload (no request blocks another); the cache converges on one file_id.
  • thumbnail params are never cached — telegram doesn't allow reusing thumbnails by file_id.
  • pairs well with @yaebal/again — its retries re-run the cache hooks, so a flood-wait retry still hits the cache.

see the media-studio example for a runnable bot.