@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

terminal
pnpm add @yaebal/hydrate

usage

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.

bot.ts
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.ts
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.

methodcallsresolves to
editText(text, extra?)editMessageTextHydratedMessage | true
editCaption(caption, extra?)editMessageCaptionHydratedMessage | true
editReplyMarkup(replyMarkup?, extra?)editMessageReplyMarkupHydratedMessage | true
delete(extra?)deleteMessagetrue
pin(extra?)pinChatMessagetrue
unpin(extra?)unpinChatMessagetrue
forward(chatId, extra?)forwardMessageHydratedMessage
copy(chatId, extra?)copyMessageMessageId
react(reaction, extra?)setMessageReactiontrue

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.

react.ts
// 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.

standalone.ts
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

exportsignaturedescription
hydrate() => BotPlugin<Context, HydrateFlavor>installable plugin — hydrates api results and adds ctx.hydrate()
hydrateApi(api: Api) => Apiinstall the after-hook on a client directly; idempotent per client
hydrateMessage<M extends Message>(api: Api, message: M) => M & HydrateMessageMethodshydrate one message by hand, bound to a given client

HydrateFlavor

membersignaturedescription
hydrate<M extends Message>(message: M) => M & HydrateMessageMethodsattach 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:

hydrate.test.ts
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" },
}));
types vs runtime. hydration happens at runtime for every message-returning call, but 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.