@yaebal/media-group
collect albums into one handler call or a ctx.mediaGroup pass-through
install
pnpm add @yaebal/media-groupusage
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.
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.
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 inmessage_idorder even when they arrive shuffled. - edited updates (
edited_message,edited_channel_post,edited_business_message) pass through untouched by default — opt in viaupdates, and each kind collects into its own group so an edit never pollutes a freshly arriving album.
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.
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.
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
| export | signature | description |
|---|---|---|
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[]) => unknown | called 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 |
MediaGroupPlugin | Plugin<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
| field | type | default | description |
|---|---|---|---|
delayMs | number | 200 | how long to wait for the next album part before flushing. |
updates | MediaGroupUpdateName[] | ["message", "channel_post", "business_message"] | which update kinds are collected. kinds not listed pass through untouched. |
onError | (error, ctx, messages) => unknown | console.error | called 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:
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.
onError instead of bot.onError.