@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
pnpm add @yaebal/ephemeralusage
install ephemeral() once. it adds ctx.replyEphemeral and ctx.sendEphemeral via derive — no other plugin dependency required.
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).
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" recoverytargeting another user
// 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.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:
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.
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.
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:
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
| export | signature | description |
|---|---|---|
ephemeral | (options?: EphemeralOptions) => Plugin<Context, EphemeralControl> | installable plugin — adds ctx.replyEphemeral / ctx.sendEphemeral |
wrapEphemeralMessage | (api, sent: Message, options?) => EphemeralMessage | wrap an already-sent ephemeral message in a handle |
supportsEphemeral | (chat?: Pick<Chat, "type">) => boolean | group/supergroup check |
isExpiredEphemeralError | (error: unknown) => boolean | the default "message is gone" detector (a telegram 400 saying not found / expired / invalid) |
EphemeralControl
| member | signature | description |
|---|---|---|
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
| method | description |
|---|---|
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
| field | type | default | description |
|---|---|---|---|
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) => boolean | isExpiredEphemeralError | override 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:
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,
});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.