@yaebal/media-group

collect albums into one handler call or a ctx.mediaGroup pass-through

install

terminal
pnpm add @yaebal/media-group

usage

telegram delivers an album as separate updates that share a media_group_id. this plugin buffers them — per update kind, chat and group id — and hands over the whole album in one piece: sorted by message_id, deduplicated, flushed after a short debounce or immediately once all 10 possible parts arrived. two modes:

handler mode — pass a handler and it fires once per album. album parts are consumed; they never reach middleware installed after the plugin.

bot.ts
import { Bot } from "@yaebal/core";
import { mediaGroup } from "@yaebal/media-group";

const bot = new Bot(token);

bot.install(
  mediaGroup(async (ctx, messages) => {
    // ctx — the context of the album's first message
    // messages — every part, sorted by message_id
    await ctx.reply(`album received: ${messages.length} parts`);
  }),
);

pass-through mode — no handler: the album's first update continues down the chain with ctx.mediaGroup set to the whole group, so filter queries, sessions and downstream plugins keep working. the remaining parts are consumed.

pass-through.ts
bot.install(mediaGroup()).on("message", async (ctx) => {
  if (ctx.mediaGroup) {
    // the album's first message, with the whole group attached
    return ctx.reply(`album of ${ctx.mediaGroup.length}`);
  }
  // plain messages arrive as usual — ctx.mediaGroup is undefined
});

what appears on ctx

in pass-through mode the plugin's Out is MediaGroupExtension — after .install(mediaGroup()) every handler sees ctx.mediaGroup?: Message[]: the sorted album on the group's first update, undefined everywhere else. handler mode adds nothing to the context.

collection rules

  • groups are keyed by update kind + chat id + media_group_id. the id is only unique within a chat — a channel album auto-forwarded into its linked discussion group keeps the channel's id, and the two never merge.
  • each incoming part extends the debounce window by delayMs; a full album of 10 parts flushes immediately.
  • parts are deduplicated by message_id (webhook retries can redeliver an update) and flushed in message_id order even when they arrive shuffled.
  • edited updates (edited_message, edited_channel_post, edited_business_message) pass through untouched by default — opt in via updates, and each kind collects into its own group so an edit never pollutes a freshly arriving album.
save-album.ts
bot.install(
  mediaGroup(
    async (ctx, messages) => {
      for (const msg of messages) {
        const photo = msg.photo?.[msg.photo.length - 1];
        if (photo) await savePhoto(photo.file_id);
      }
      await ctx.reply(`saved ${messages.length} items`);
    },
    {
      delayMs: 300,
      // albums flush from a timer — outside bot.onError's reach
      onError: (error, _ctx, messages) =>
        console.error(`album of ${messages.length} failed`, error),
    },
  ),
);

edited albums

listing an edited kind in updates batches caption edits the same way new albums are batched. the handler tells them apart by ctx.updateType.

edited-albums.ts
bot.install(
  mediaGroup(
    (ctx, messages) => {
      // ctx.updateType tells the kinds apart: "message" vs "edited_message"
      if (ctx.updateType === "edited_message") return handleEdit(messages);
      return handleAlbum(messages);
    },
    { updates: ["message", "edited_message"] },
  ),
);

graceful shutdown

mediaGroup(...) returns the plugin with a flush() method: it delivers every pending album right away and resolves once all handlers settle — wire it to bot.onStop so a half-collected album isn't lost on shutdown.

shutdown.ts
const albums = mediaGroup(async (ctx, messages) => {
  await persist(messages);
});

bot.install(albums);

// deliver half-collected albums instead of dropping them
bot.onStop(() => albums.flush());

api

exportsignaturedescription
mediaGroup(handler: MediaGroupHandler<C>, options?: MediaGroupOptions<C>) => MediaGroupPlugin<C>handler mode — fires once per album, parts are consumed
mediaGroup(options?: MediaGroupOptions<C>) => MediaGroupPlugin<C, MediaGroupExtension>pass-through mode — first update continues with ctx.mediaGroup
MediaGroupHandler(ctx: C, messages: Message[]) => unknowncalled once per album, messages sorted by message_id
MediaGroupOptions{ delayMs?, updates?, onError? }see below
MediaGroupExtension{ mediaGroup?: Message[] }what pass-through mode adds to the context
MediaGroupPluginPlugin<In, Out> & { flush(): Promise<void> }the plugin function plus album controls
MediaGroupUpdateName"message" | "edited_message" | …the update kinds that can carry a media_group_id

MediaGroupOptions

fieldtypedefaultdescription
delayMsnumber200how long to wait for the next album part before flushing.
updatesMediaGroupUpdateName[]["message", "channel_post", "business_message"]which update kinds are collected. kinds not listed pass through untouched.
onError(error, ctx, messages) => unknownconsole.errorcalled when the album handler (or the downstream chain in pass-through mode) throws.

typed contexts in handler mode

the handler's ctx defaults to the base Context. when the plugin is installed after enriching plugins, their additions are present on the context at runtime — pin the type parameter to see them:

typed-handler.ts
import type { Context } from "@yaebal/core";
import type { Session } from "@yaebal/session";

// installed after session()? pin the handler's context view explicitly —
// the enrichment is on ctx at runtime either way
bot.install(
  mediaGroup<Context & { session: Session<Data> }>(async (ctx, messages) => {
    ctx.session.albums += 1;
  }),
);

pass-through mode doesn't need this — the chain's accumulated context type flows into downstream handlers as usual.

testing

the package ships a node:test suite covering consumption, cross-chat keying, dedup, ordering, the 10-part short-circuit, error routing and both modes — and the media-studio example doubles as a live smoke test: send an album and it replies with the photo/video breakdown.

state is in-memory. all parts of an album must land in the same process — behind multiple workers or serverless instances, route each chat's updates to one instance. albums also flush from a timer, outside the middleware chain, which is why errors go to onError instead of bot.onError.