@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
pnpm add @yaebal/media-cachetransparent 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.
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.
// lower-level form, useful when you only have an Api instance
cache.attach(bot.api);what keys what
| source | cache key | note |
|---|---|---|
media.path("./a.png") | the path | zero extra I/O on a hit |
media.url("https://…") | the url | telegram skips re-downloading it |
media.buffer(bytes) | sha-256 of the bytes | same 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
// 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.
// 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
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.
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
const cache = mediaCache({
onEvent: (e) => {
// { type: "hit" | "store", method, key, fileId }
// { type: "evict", key, reason: "invalidated" | "rejected" }
metrics.increment(`media_cache_${e.type}`);
},
});api
| export | kind | description |
|---|---|---|
mediaCache(options?) | function | creates a MediaCache |
MediaCache | interface | the object returned by mediaCache() |
MediaCacheOptions | interface | storage?, scope?, onEvent? |
MediaCacheEvent | type | hit / store / evict observations |
CachedSend | type | the manual-mode sender signature |
MediaCache methods
| method | description |
|---|---|
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
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.
thumbnailparams are never cached — telegram doesn't allow reusing thumbnails byfile_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.