@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);

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,
  receiver_user_id: userId,
  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(text, extra?) => Promise<EphemeralMessage>answer the update's author privately; falls back per fallback in private chats
sendEphemeral(receiverUserId, text, extra?) => Promise<EphemeralMessage>ephemeral message to a specific user in the current group; never falls back

EphemeralOptions

fieldtypedefaultdescription
fallback"message" | "error""message"what replyEphemeral does in a private chat
onExpired"throw" | "ignore" | "resend""throw"what edit/editReplyMarkup 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?.receiver_user_id === 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.equal(env.lastApiCall("sendMessage")?.params?.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.