@yaebal/hydrate
api call results come back "hydrated" — the Message returned by ctx.send, ctx.reply or api.sendMessage carries methods
bound to itself, so a follow-up edit, delete, pin, forward, copy or reaction needs no chat_id or message_id. inspired by grammy's hydrate plugin, fitted to
yaebal idioms (it hangs off @yaebal/core's api.after hook).
install
pnpm add @yaebal/hydrateusage
install hydrate() once. every Message an api call returns comes back
with the methods attached at runtime. ctx.send still types its result as plain Message (core owns that signature), so cast to HydratedMessage, or run
the value through ctx.hydrate to type it without a cast.
import { Bot } from "@yaebal/core";
import { hydrate, type HydratedMessage } from "@yaebal/hydrate";
const bot = new Bot(process.env.BOT_TOKEN!)
.install(hydrate());
bot.command("start", async (ctx) => {
const msg = (await ctx.send("counting…")) as HydratedMessage;
await msg.editText("done");
await msg.pin();
// later…
await msg.delete();
});
await bot.start();ctx.hydrate
hydrate() also decorates the context with ctx.hydrate(message), for
messages the api never handed you directly — most often the callback query's message. it's
idempotent, so calling it on an already-hydrated value is a no-op, and it binds to ctx.api, so the shortcuts route through whatever client built the update (a
webhook-reply view, a test mock, …).
bot.on("callback_query", async (ctx) => {
const message = ctx.callbackQuery?.message;
// the api never returned this message — hydrate it explicitly (idempotent).
if (message) await ctx.hydrate(message).editText("updated");
});methods
each method targets the message it's attached to.
| method | calls | resolves to |
|---|---|---|
editText(text, extra?) | editMessageText | HydratedMessage | true |
editCaption(caption, extra?) | editMessageCaption | HydratedMessage | true |
editReplyMarkup(replyMarkup?, extra?) | editMessageReplyMarkup | HydratedMessage | true |
delete(extra?) | deleteMessage | true |
pin(extra?) | pinChatMessage | true |
unpin(extra?) | unpinChatMessage | true |
forward(chatId, extra?) | forwardMessage | HydratedMessage |
copy(chatId, extra?) | copyMessage | MessageId |
react(reaction, extra?) | setMessageReaction | true |
text and caption accept a plain string or a format/fmt result. the business
connection a message belongs to is threaded into its edits and pins automatically, the same way ctx.send routes new messages.
// a bare emoji string is wrapped into { type: "emoji", emoji }
await msg.react("👍");
// arrays and full ReactionType values pass through untouched
await msg.react([{ type: "custom_emoji", custom_emoji_id: "123" }, "🔥"]);how it works
hydration is shape-detected: any result with a numeric message_id and a chat object gets the methods, so it covers every message-returning method — including
future ones — with no per-method list. sendMediaGroup's array is walked element by
element. copyMessage's MessageId (no chat) is deliberately
left alone. the methods are added as non-enumerable properties, so JSON.stringify(msg) and object spreads see the plain telegram payload, never the
helpers.
standalone
the extension points, if you don't want the full plugin: hydrateApi(api) installs the
after-hook directly on a client, and hydrateMessage(api, message) hydrates one
message by hand.
import { hydrateApi, hydrateMessage } from "@yaebal/hydrate";
// install the after-hook directly on a client (the plugin-free form)
hydrateApi(bot.api);
// or hydrate one message by hand, bound to a given client
const msg = hydrateMessage(bot.api, someMessage);
await msg.delete();api
| export | signature | description |
|---|---|---|
hydrate | () => BotPlugin<Context, HydrateFlavor> | installable plugin — hydrates api results and adds ctx.hydrate() |
hydrateApi | (api: Api) => Api | install the after-hook on a client directly; idempotent per client |
hydrateMessage | <M extends Message>(api: Api, message: M) => M & HydrateMessageMethods | hydrate one message by hand, bound to a given client |
HydrateFlavor
| member | signature | description |
|---|---|---|
hydrate | <M extends Message>(message: M) => M & HydrateMessageMethods | attach the methods to a message the api didn't return; idempotent |
testing
@yaebal/test gives each api its own mock client, so
install hydrateApi on env.api and return a full Message shape from the send you want hydrated:
import { hydrateApi } from "@yaebal/hydrate";
import { createTestEnv } from "@yaebal/test";
const env = createTestEnv(bot);
hydrateApi(env.api);
// the mock's default send result is just { message_id } — return a full Message
// shape so shape-detection hydrates it:
env.onApi("sendMessage", (p) => ({
message_id: 1,
date: 0,
chat: { id: p?.chat_id, type: "private" },
}));ctx.send's static return type stays Message — core owns that
signature and hydrate can't retype it. reach for ctx.hydrate(...) or a HydratedMessage cast when you want the methods typed.