@yaebal/ephemeral

answer in a group so only the asker (and the bot) sees it, via telegram's ephemeral messages (bot api 10.2+). ctx.replyEphemeral() returns a typed handle that hides the awkward addressing (message_id is 0 on ephemeral messages — edits go through chat_id + receiver_user_id + ephemeral_message_id), falls back to a normal message in private chats, and has a policy for the "message already expired" case. no more spamming the whole chat with "you are not an admin".

install

terminal
pnpm add @yaebal/ephemeral

usage

install ephemeral() once. it adds ctx.replyEphemeral and ctx.sendEphemeral via derive — no other plugin dependency required.

bot.ts
import { Bot } from "@yaebal/core";
import { ephemeral } from "@yaebal/ephemeral";

const bot = new Bot(process.env.BOT_TOKEN!)
  .install(ephemeral());

bot.command("stats", async (ctx) => {
  // in a group: a real ephemeral message, visible only to the sender.
  // in a private chat: a normal message (same visibility), same handle.
  const msg = await ctx.replyEphemeral("crunching…");
  const stats = await computeStats(ctx.from.id);
  await msg.edit(`you sent ${stats.count} messages this week`);
});

await bot.start();

the handle

both senders resolve to an EphemeralMessage — one interface whether the message is a real ephemeral one or the private-chat fallback (then backed by editMessageText / editMessageReplyMarkup / deleteMessage).

bot.ts
const msg = await ctx.replyEphemeral("step 1/3…");

await msg.edit("step 2/3…");        // editEphemeralMessageText
await msg.editReplyMarkup(keyboard); // editEphemeralMessageReplyMarkup
await msg.delete();                  // deleteEphemeralMessage; false if already gone

msg.isEphemeral;        // false on the private-chat fallback path
msg.ephemeralMessageId; // may change after an onExpired: "resend" recovery

targeting another user

bot.ts
// a private nudge to a specific user in the current group — not the update's
// author. never falls back: outside group/supergroup chats it rejects, because
// a normal message there would be visible to everyone.
bot.command("report", async (ctx) => {
  await ctx.sendEphemeral(ADMIN_ID, `new report from ${ctx.from.first_name}`);
  await ctx.replyEphemeral("thanks — the admins have been notified");
});

options

bot.ts
bot.install(
  ephemeral({
    // replyEphemeral in a private chat: "message" (default) sends a normal
    // message behind the same handle; "error" rejects instead.
    fallback: "message",
    // edit/editReplyMarkup on an expired ephemeral message:
    // "throw" (default), "ignore" (resolve false), or "resend" (send the new
    // content as a fresh ephemeral message and retarget the handle).
    onExpired: "resend",
    // override the "message is gone" detector if telegram's wording changes.
    isExpiredError: (error) => myDetector(error),
  }),
);

delete() is always idempotent: an already-gone message resolves false instead of throwing, whatever onExpired is set to.

ephemeral commands

pair with @yaebal/commands' — ephemeral() there marks the menu entry is_ephemeral, this plugin makes the answer private too:

bot.ts
import { commands } from "@yaebal/commands";

// is_ephemeral in the menu: telegram shows the /stats invocation only to its
// sender and expects an answer within ~15 seconds — answer it ephemerally.
const cmd = commands().ephemeral("stats", "your personal stats", async (ctx) => {
  await ctx.replyEphemeral(`you: ${await stats(ctx.from.id)}`);
});

bot.install(ephemeral()).install(cmd.plugin());
await cmd.sync(bot.api);

rich content and media

replyEphemeral and sendEphemeral accept text, a format result, { rich_message } or { media }. rich content accepts InputRichMessage or a RichDocument from @yaebal/rich, with no required plugin dependency. media supports photo, video, animation, audio, document, live photo and voice note inputs. voice notes can be sent but cannot be edited with editMedia.

rich-media.ts
import { media } from "@yaebal/core";

const summary = await ctx.replyEphemeral({
  rich_message: { html: "<h2>your summary</h2><p>only you see this.</p>" },
});
await summary.editRich({ blocks: [{ type: "divider" }] });

const photo = await ctx.replyEphemeral({ media: { type: "photo", media: "file-id" } });
await photo.editMedia({ type: "photo", media: media.path("./updated.jpg") });
await photo.editCaption("ready", { show_caption_above_media: true });

new uploads work in media edits. the same handle methods use regular send/edit methods for private-chat fallbacks. extra options are typed as EphemeralSendOptions, EphemeralEditOptions, EphemeralMediaOptions and EphemeralCaptionOptions.

personalized callback overlays

ctx.replaceWithEphemeral(content) supplies the callback id and replace_callback_query_message to display a personal overlay in place of the original group message. it requires a callback from a regular group message. edit existing ephemeral messages through their handles instead; private chats and inline-only callbacks reject.

overlay.ts
bot.on("callback_query:data", async (ctx) => {
  await ctx.replaceWithEphemeral({ rich_message: { html: "<p>your personal view</p>" } });
});

incoming ephemeral commands are answered using reply_parameters.ephemeral_message_id. answer within 15 seconds, then continue longer work by editing the returned handle. core's ctx.reply() also addresses incoming ephemeral messages correctly and keeps the answer ephemeral.

expiry recovery preserves known rich/media content, captions, formatting and keyboards, and never replays old callback replacements or replies. use reusable file ids, buffers or paths for recovery; a consumed one-shot stream cannot be resent. unknown read-side rich content from wrapEphemeralMessage cannot be reconstructed for markup-only recovery. unrelated missing-chat and invalid-content errors are rethrown.

standalone

sent an ephemeral message through the raw api? wrap it:

bot.ts
import { wrapEphemeralMessage } from "@yaebal/ephemeral";

const sent = await bot.api.call("sendMessage", {
  chat_id: chatId,
  ephemeral_message_parameters: { receiver_user_id: userId }, // bot api 10.3
  text: "psst",
});
const msg = wrapEphemeralMessage(bot.api, sent, { onExpired: "ignore" });
await msg.edit("psst — updated");

api

exportsignaturedescription
ephemeral(options?: EphemeralOptions) => Plugin<Context, EphemeralControl>installable plugin — adds ctx.replyEphemeral / ctx.sendEphemeral
wrapEphemeralMessage(api, sent: Message, options?) => EphemeralMessagewrap an already-sent ephemeral message in a handle
supportsEphemeral(chat?: Pick<Chat, "type">) => booleangroup/supergroup check
isExpiredEphemeralError(error: unknown) => booleanthe default "message is gone" detector (a telegram 400 saying not found / expired / invalid)

EphemeralControl

membersignaturedescription
replyEphemeral(content, extra?) => Promise<EphemeralMessage>answer the update's author privately; falls back per fallback in private chats
sendEphemeral(receiverUserId, content, extra?) => Promise<EphemeralMessage>ephemeral message to a specific user in the current group; never falls back

handle methods

methoddescription
edit(text, extra?)replace text and formatting
editRich(input, extra?)replace rich content
editMedia(media, extra?)replace media, including new uploads
editCaption(caption, extra?)replace caption, entities and caption placement
editReplyMarkup(markup)replace the keyboard; undefined clears it
delete()delete; false if already gone

ctx.replaceWithEphemeral(content, extra?) returns the same EphemeralMessage handle.

EphemeralOptions

fieldtypedefaultdescription
fallback"message" | "error""message"what replyEphemeral does in a private chat
onExpired"throw" | "ignore" | "resend""throw"what handle edits do when the message already expired
isExpiredError(error: unknown) => booleanisExpiredEphemeralErroroverride the expiry detector

testing

@yaebal/test's auto-stub answers every send* with a plain message_id — teach it the ephemeral answer shape with onApi, then assert on the recorded calls:

ephemeral.test.ts
const bot = new Composer<Context>()
  .install(ephemeral())
  .command("go", async (ctx) => void (await ctx.replyEphemeral("hi")));

const env = createTestEnv(bot);
// teach the auto-stub telegram's ephemeral answer shape
env.onApi("sendMessage", (params) =>
  params?.ephemeral_message_parameters === undefined
    ? { message_id: 42 }
    : { message_id: 0, ephemeral_message_id: 900 });

const group = env.createChat({ type: "supergroup" });
await env.createUser({ id: 7 }).in(group).sendCommand("go");

assert.deepEqual(env.lastApiCall("sendMessage")?.params?.ephemeral_message_parameters, {
  receiver_user_id: 7,
});
best-effort by design. delivery to offline users is not guaranteed, edits and deletes may never reach the receiver, and telegram reuses ephemeral_message_ids after expiry — never persist a handle (or its ids) in a session or database, and never gate a required flow on an ephemeral reply. treat ephemeral messages as UI sugar, not as state.