@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);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,
receiver_user_id: userId,
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 | (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
| field | type | default | description |
|---|---|---|---|
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) => 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?.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);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.