1.3.8-beta.11: трансляция умеет закреплять — кнопка «📌 Закреп» в /broadcast у каждого места: первое сообщение каждой копии закрепляется тихо (без уведомлений), твои сообщения из ТГ тоже (просьба через бот-репорт, 2026-10-15); без права закреплять закреп выключается сам и тема говорит почему; строка «… закрепил» от своего закрепа удаляется (нужно право удалять; channel_post включён ради каналов, чужие посты каналов игнорируются); ссылки «👥 В группу» / «📢 В канал» сразу просят права закреплять и удалять

This commit is contained in:
Folist 2026-10-15 19:13:10 +03:00
parent b6e282576a
commit 932db96f14
8 changed files with 202 additions and 21 deletions

View file

@ -159,9 +159,10 @@ FILES_LISTEN= # локальный порт службы для ваш
Новые сообщения чата MAX можно копировать в обычные группы и каналы Telegram — для тех, у кого MAX нет. Например, чат садового товарищества — в канал, который читают соседи.
- **Включить.** Напишите `/broadcast` в теме чата и нажмите «👥 В группу» или «📢 В канал»: Telegram покажет ваши группы или каналы и сам добавит туда бота — в канал администратором с правом публиковать. Если бот там уже есть — «🔎 Бот уже там»: выбор откроется в личке с ботом.
- **Включить.** Напишите `/broadcast` в теме чата и нажмите «👥 В группу» или «📢 В канал»: Telegram покажет ваши группы или каналы и сам добавит туда бота администратором (публиковать, закреплять). Если бот там уже есть — «🔎 Бот уже там»: выбор откроется в личке с ботом.
- **Мест — сколько угодно.** Один чат можно транслировать в несколько групп и каналов, а в одну группу — несколько чатов. `/broadcast` в теме показывает, куда идёт трансляция, и останавливает любую кнопкой «⏹».
- **Что копируется.** Новые сообщения с момента включения: текст, фото, файлы, голосовые, с подписью автора, как в теме. Вместо «🧑 Вы» стоит ваше имя из MAX, ваши сообщения, написанные в теме из Telegram, тоже идут туда. Правки и удаления в MAX повторяются, пока мост не перезапускался. Опросы — текстом: голоса из Telegram в MAX не попадут. История до включения не переносится, сообщения, пришедшие пока мост был выключен, — догоняются.
- **Закреп «📌».** Кнопка «📌 Закреп» в `/broadcast` включает закрепление каждого нового сообщения в этом месте — тихо, без уведомлений. В группе, где все пишут, сообщения из MAX легко найти по закрепам. Боту нужно право закреплять сообщения (в канале — редактировать); с правом удалять он ещё и убирает строку «… закрепил».
- **В одну сторону.** Что пишут в группе трансляции, в MAX не уходит — бот там молчит. Чтобы участники только читали, выберите канал или закройте отправку сообщений в настройках группы.
- Если бота удалят из группы или канала, трансляция туда остановится сама, а в теме чата появится сообщение.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "telemax",
"version": "1.3.8-beta.10",
"version": "1.3.8-beta.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "telemax",
"version": "1.3.8-beta.10",
"version": "1.3.8-beta.11",
"license": "AGPL-3.0-only",
"dependencies": {
"dotenv": "^17.2.3",

View file

@ -2,7 +2,7 @@
"name": "telemax",
"private": true,
"license": "AGPL-3.0-only",
"version": "1.3.8-beta.10",
"version": "1.3.8-beta.11",
"type": "module",
"scripts": {
"dev": "tsx watch src/server/app.ts",

View file

@ -54,6 +54,8 @@ export interface Broadcasts {
copyDelete(maxChatId: string, maxMessageId: unknown): void;
/** True for a chat that receives a broadcast — the bridge leaves its updates alone. */
isTarget(chatId: number): Promise<boolean>;
/** An update from a broadcast's place: the «… закрепил» line Telegram adds for the bot's own pin goes away. */
onTargetUpdate(ctx: Context): Promise<void>;
/** The DM part of switching one on (deep link, chat picker); true when the update was ours. */
handlePrivate(ctx: Context): Promise<boolean>;
/** A group or channel outside the bridge: the bot added there by a card's link (true = ours, nothing else may answer it). */
@ -102,17 +104,25 @@ export function broadcastCard(
'Новые сообщения этого чата можно копировать в группы и каналы Telegram — для тех, у кого нет MAX. Мест — сколько угодно. История до включения не переносится, а что пишут там — в MAX не уходит.',
);
} else {
lines.push('Новые сообщения копируются сюда:', ...list.map((b) => `• ${where(b)} — с ${day(b.since)}`));
lines.push(
'Новые сообщения копируются сюда:',
...list.map((b) => `• ${where(b)} — с ${day(b.since)}${b.pin ? ', 📌 закрепляются' : ''}`),
'',
'«📌» — закреплять там каждое новое сообщение, тихо: в группе, где все пишут, их легко найти по закрепам.',
);
}
if (links) {
lines.push(
'',
'«👥 В группу» / «📢 В канал» — Telegram покажет ваши группы или каналы и сам добавит туда бота (в канал — администратором с правом публиковать). Бот уже там — «🔎 Бот уже там».',
'«👥 В группу» / «📢 В канал» — Telegram покажет ваши группы или каналы и сам добавит туда бота администратором (публиковать, закреплять). Бот уже там — «🔎 Бот уже там».',
);
} else {
lines.push('', '⚠️ Бот ещё не знает своё имя — повторите /broadcast через минуту.');
}
const rows: InlineKeyboardButton[][] = list.map((b) => [Markup.button.callback(`⏹ Остановить: ${truncateCodePoints(b.targetTitle, 40, '…')}`, `tlmx_bc:stop:${b.id}`)]);
const rows: InlineKeyboardButton[][] = list.map((b) => [
Markup.button.callback(`⏹ Остановить: ${truncateCodePoints(b.targetTitle, 30, '…')}`, `tlmx_bc:stop:${b.id}`),
Markup.button.callback(b.pin ? '📌 Закреп: вкл' : '📌 Закреп: выкл', `tlmx_bc:pin:${b.id}`),
]);
if (links) {
rows.push([Markup.button.url('👥 В группу', links.group), Markup.button.url('📢 В канал', links.channel)]);
rows.push([Markup.button.url('🔎 Бот уже там', links.pick)]);
@ -124,11 +134,15 @@ export function broadcastCard(
/** The card's three ways to add a place — all carry the same one-time code (see createBroadcasts). */
export type AddLinks = { group: string; channel: string; pick: string };
/** Telegram's own «add the bot» links: a group gets `/start@bot bc_<code>` (the code says which chat); a channel makes the bot an admin with these rights. */
/**
* Telegram's own «add the bot» links: a group gets `/start@bot bc_<code>` (the code says which chat);
* both make the bot an admin with these rights — pinning for «📌» (a channel pins with
* edit_messages), deleting for the «… закрепил» line each pin leaves.
*/
export function addLinks(username: string, code: string): AddLinks {
return {
group: `https://t.me/${username}?startgroup=bc_${code}`,
channel: `https://t.me/${username}?startchannel&admin=post_messages+edit_messages`,
group: `https://t.me/${username}?startgroup=bc_${code}&admin=pin_messages+delete_messages`,
channel: `https://t.me/${username}?startchannel&admin=post_messages+edit_messages+delete_messages`,
pick: `https://t.me/${username}?start=bc_${code}`,
};
}
@ -148,6 +162,13 @@ const CHANNEL_RIGHTS: ChatAdministratorRights = {
};
const LINK_TTL_MS = 30 * 60_000;
const COPY_MAP_CAP = 1000;
const PINNED_CAP = 200;
/** Telegram refusing the bot a pin for want of rights (not an admin, no «Закрепление сообщений»). */
export function isPinRefusal(err: unknown): boolean {
const r = (err as { response?: { error_code?: number; description?: string } } | null)?.response;
return r?.error_code === 403 || /not enough rights|need administrator rights|CHAT_ADMIN_REQUIRED|have no rights/i.test(r?.description ?? '');
}
const CANCEL = '✖️ Отмена';
type CopyLink = { broadcastId: string; targetChatId: number; ids: number[]; anchor?: number };
@ -214,6 +235,34 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
}
}
/** The bot's own pins, `${chatId}:${messageId}` — only their «… закрепил» line is removed (onTargetUpdate). */
const pinned = new Set<string>();
/** «📌»: the first copy of a message pinned, silently. No right to pin → the flag goes off and the topic says why, once. */
async function pinCopy(b: Broadcast, messageId: number): Promise<void> {
const key = `${b.targetChatId}:${messageId}`;
if (pinned.size >= PINNED_CAP) pinned.delete(pinned.values().next().value!);
pinned.add(key);
try {
await withFloodRetry(() => bot.telegram.pinChatMessage(b.targetChatId, messageId, { disable_notification: true }));
} catch (err) {
pinned.delete(key);
if (!isPinRefusal(err)) {
logger.error(`Broadcast ${b.id}: pinning message ${messageId} in ${b.targetChatId} failed`, err);
return;
}
if (!(await store.setPin(b.id, false))) return;
logger.info(`Broadcast ${b.id}: «📌» off — no right to pin in ${b.targetChatId} (${(err as Error).message})`);
await notifyTopic(b.maxChatId, `📌 Закреп в «${b.targetTitle}» выключен: у бота там нет права закреплять сообщения. Дайте его и включите «📌» снова — /broadcast.`);
}
}
/** What the bot may do in a place for «📌»: pin (a channel pins with the right to edit) and delete the «закрепил» line. */
async function pinRights(b: Broadcast): Promise<{ pin: boolean; del: boolean }> {
const botId = bot.botInfo?.id;
const m = botId != null ? await bot.telegram.getChatMember(b.targetChatId, botId).catch(() => undefined) : undefined;
if (m?.status !== 'administrator') return { pin: false, del: false };
return { pin: b.targetType === 'channel' ? m.can_edit_messages === true : m.can_pin_messages !== false, del: m.can_delete_messages === true };
}
/** Reply target in this broadcast for a quoted MAX message — its copy's anchor, if it was copied. */
function replyTo(b: Broadcast, maxChatId: string, quote?: { maxMessageId: unknown }): number | undefined {
if (!quote) return undefined;
@ -241,6 +290,7 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
const m = await withFloodRetry(() => bot.telegram.copyMessage(b.targetChatId, targetGroupId, id, ids.length === 0 ? replyExtra(reply) : {}));
ids.push(m.message_id);
}
if (b.pin && ids.length) await pinCopy(b, ids[0]!);
} finally {
if (ids.length) remember(keyOf(c.maxChatId, c.maxMessageId), { broadcastId: b.id, targetChatId: b.targetChatId, ids, anchor });
}
@ -289,6 +339,7 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
await send(prefix.trimEnd());
await copy();
}
if (b.pin && ids.length) await pinCopy(b, ids[0]!);
} finally {
if (ids.length) remember(keyOf(maxChatId, maxMessageId), { broadcastId: b.id, targetChatId: b.targetChatId, ids, anchor });
}
@ -480,6 +531,19 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
return true;
}
async function onTargetUpdate(ctx: Context): Promise<void> {
const msg = (ctx.channelPost ?? ctx.message) as { message_id: number; pinned_message?: { message_id: number } } | undefined;
const chatId = ctx.chat?.id;
const pinnedId = msg?.pinned_message?.message_id;
if (!msg || chatId == null || pinnedId == null) return;
// Someone else's pin is theirs — only the line of the bot's own goes.
if (!pinned.delete(`${chatId}:${pinnedId}`)) return;
await bot.telegram
.deleteMessage(chatId, msg.message_id)
.then(() => logger.info(`Broadcast: removed the «pinned» line of message ${pinnedId} in ${chatId}`))
.catch((err) => logger.info(`Broadcast: the «pinned» line in ${chatId} stays (${(err as Error).message})`));
}
function register(): void {
bot.command('broadcast', async (ctx) => {
const topicId = ctx.message.message_thread_id;
@ -506,6 +570,36 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
const { text, markup } = await card(maxChatId, ctx.from.id);
await ctx.editMessageText(text, { reply_markup: markup }).catch(() => {});
});
bot.action(/^tlmx_bc:pin:([\w-]+)$/, async (ctx) => {
const b = await store.get(ctx.match[1]!);
if (!b) {
await ctx.answerCbQuery('Эта трансляция уже остановлена').catch(() => {});
} else if (b.pin) {
await store.setPin(b.id, false);
await ctx.answerCbQuery('📌 Закреп выключен').catch(() => {});
} else {
const rights = await pinRights(b);
if (!rights.pin) {
const right = b.targetType === 'channel' ? '«Редактирование сообщений»' : '«Закрепление сообщений»';
await ctx.answerCbQuery(`Чтобы закреплять в «${truncateCodePoints(b.targetTitle, 40, '…')}», бот должен быть там администратором с правом ${right}. Выдайте его и нажмите ещё раз.`, { show_alert: true }).catch(() => {});
} else {
await store.setPin(b.id, true);
logger.info(`Broadcast ${b.id}: «📌» on in ${b.targetChatId}`);
await ctx
.answerCbQuery(
rights.del
? '📌 Новые сообщения будут закрепляться'
: '📌 Включено. Строка «… закрепил» останется в чате: у бота нет права удалять сообщения — дайте его, чтобы её не было.',
{ show_alert: !rights.del },
)
.catch(() => {});
}
}
if (!b) return;
const { text, markup } = await card(b.maxChatId, ctx.from.id);
await ctx.editMessageText(text, { reply_markup: markup }).catch(() => {});
});
}
return {
@ -514,6 +608,7 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
copyEdit,
copyDelete,
isTarget: (chatId) => store.isTarget(chatId),
onTargetUpdate,
handlePrivate,
handleChat,
register,

View file

@ -1682,9 +1682,15 @@ export function wireBridge({
await bugReports.handleIncomingPrivate(ctx);
return;
}
// A broadcast's group or channel: readers' messages there go nowhere. And no reject for the
// service message of the bot being added — the chat picker does exactly that before it says which chat.
if (await broadcasts.isTarget(ctx.chat.id).catch(() => false)) return;
// A broadcast's group or channel: readers' messages there go nowhere (only the «закрепил» line of
// a «📌» pin is looked at). And no reject for the service message of the bot being added — the
// chat picker does exactly that before it says which chat.
if (await broadcasts.isTarget(ctx.chat.id).catch(() => false)) {
await broadcasts.onTargetUpdate(ctx).catch((err) => logger.error('Broadcast: update from a place failed', err));
return;
}
// Any other channel's post (channel_post is on for «📌»): never a reply into someone's channel.
if (ctx.channelPost || ctx.editedChannelPost) return;
// «👥 В группу» from a /broadcast card: Telegram added the bot and sent `/start@bot bc_<code>` here.
if (
await broadcasts.handleChat(ctx).catch((err) => {

View file

@ -698,10 +698,11 @@ async function launchTelegramBotWithRetry(bot: Telegraf, attempt = 0): Promise<v
// with no error anywhere: Telegram just never delivered the update. /donate's buttons never surfaced
// this because they're url buttons, which the client opens directly without involving the bot at all.
// my_chat_member: «📢 В канал» of a /broadcast card makes the bot a channel admin — that update is
// the only sign of which channel it was (bridge/broadcast.ts).
// the only sign of which channel it was (bridge/broadcast.ts). channel_post: the «… закрепил» line a
// broadcast's «📌» pin leaves in a channel, so it can be removed.
const launchedAt = Date.now();
bot
.launch({ allowedUpdates: ['message', 'edited_message', 'message_reaction', 'poll_answer', 'callback_query', 'my_chat_member'] })
.launch({ allowedUpdates: ['message', 'edited_message', 'channel_post', 'message_reaction', 'poll_answer', 'callback_query', 'my_chat_member'] })
.catch((err) => retryTelegramLaunch(bot, Date.now() - launchedAt >= TELEGRAM_STABLE_POLLING_MS ? 0 : attempt, err));
}

View file

@ -15,6 +15,8 @@ export interface Broadcast {
/** MAX time (ms) of the last message copied — a replayed history (/reboot, a restored topic) and
* a catch-up that relays a message twice stay at or below it and are not copied again. */
cursor: number;
/** «📌»: every message copied there gets pinned, silently — easy to find in a busy group (asked for 2026-10-15). */
pin?: boolean;
}
/**
@ -60,6 +62,16 @@ export class BroadcastStore {
return gone;
}
/** Switches «📌» on or off; the broadcast, or undefined when it is gone. */
async setPin(id: string, on: boolean): Promise<Broadcast | undefined> {
const b = (await this.load()).find((x) => x.id === id);
if (!b) return undefined;
if (on) b.pin = true;
else delete b.pin;
await this.persist(this.cache!);
return b;
}
/** Moves the broadcast's cursor forward (never back) to `time`. */
async advance(id: string, time: number): Promise<void> {
const b = (await this.load()).find((x) => x.id === id);

View file

@ -46,10 +46,10 @@ describe('broadcast texts', () => {
expect(isGoneError(new Error('socket hang up'))).toBe(false);
});
it("adds the bot through Telegram's own links: a group gets the code, a channel the admin rights", () => {
it("adds the bot through Telegram's own links: a group gets the code, both the admin rights «📌» needs", () => {
expect(addLinks('tlmx_bot', 'abc')).toEqual({
group: 'https://t.me/tlmx_bot?startgroup=bc_abc',
channel: 'https://t.me/tlmx_bot?startchannel&admin=post_messages+edit_messages',
group: 'https://t.me/tlmx_bot?startgroup=bc_abc&admin=pin_messages+delete_messages',
channel: 'https://t.me/tlmx_bot?startchannel&admin=post_messages+edit_messages+delete_messages',
pick: 'https://t.me/tlmx_bot?start=bc_abc',
});
});
@ -59,12 +59,16 @@ describe('broadcast texts', () => {
const empty = broadcastCard('Дача', [], links);
expect(empty.text).toMatch(/сам добавит туда бота/);
expect(empty.markup.inline_keyboard.flat().map((b) => b.text)).toEqual(['👥 В группу', '📢 В канал', '🔎 Бот уже там', '◀️ Готово']);
const two = broadcastCard('Дача', [broadcast(), broadcast({ id: 'b2', targetTitle: 'Новости', targetType: 'channel' })], links);
expect(two.text).toMatch(/👥 Читалка/);
expect(two.text).toMatch(/📢 Новости/);
const two = broadcastCard('Дача', [broadcast(), broadcast({ id: 'b2', targetTitle: 'Новости', targetType: 'channel', pin: true })], links);
expect(two.text).toMatch(/👥 Читалка — с \d\d\.\d\d\n/);
expect(two.text).toMatch(/📢 Новости — с \d\d\.\d\d, 📌 закрепляются/);
expect(two.markup.inline_keyboard[0]!.map((b) => b.text)).toEqual(['⏹ Остановить: Читалка', '📌 Закреп: выкл']);
expect(two.markup.inline_keyboard[1]!.map((b) => b.text)).toEqual(['⏹ Остановить: Новости', '📌 Закреп: вкл']);
expect(two.markup.inline_keyboard.flat().map((b) => ('callback_data' in b ? b.callback_data : b.text))).toEqual([
'tlmx_bc:stop:b1',
'tlmx_bc:pin:b1',
'tlmx_bc:stop:b2',
'tlmx_bc:pin:b2',
'👥 В группу',
'📢 В канал',
'🔎 Бот уже там',
@ -100,6 +104,8 @@ describe('copying into broadcasts', () => {
copyMessage: vi.fn(async (_chatId: number | string, _from: number | string, _id: number, _extra?: unknown) => ({ message_id: next++ })),
editMessageText: vi.fn(async (_chatId: number | string, _id: number, _inline: unknown, _text: string) => true),
deleteMessages: vi.fn(async (_chatId: number | string, _ids: number[]) => true),
deleteMessage: vi.fn(async (_chatId: number | string, _id: number) => true),
pinChatMessage: vi.fn(async (_chatId: number | string, _id: number, _extra?: unknown) => true),
getChatMember: vi.fn(async (_chatId: number | string, _userId: number): Promise<Record<string, unknown>> => ({ status: 'member' })),
getChat: vi.fn(async (chatId: number | string) => ({ id: chatId, title: 'Из getChat' })),
};
@ -188,6 +194,66 @@ describe('copying into broadcasts', () => {
expect(bot.telegram.sendMessage.mock.calls[1]![1]).toBe('🧑 Сергей:');
expect(bot.telegram.copyMessage.mock.calls[1]!.slice(0, 3)).toEqual([-500, '-1001', 14]);
});
it('«📌»: the first copy of every message is pinned, silently — the owner\'s too; a place without the flag is left alone', async () => {
const { store, bot, bc } = await setup(broadcast({ pin: true }), broadcast({ id: 'b2', targetChatId: -600 }));
bc.copyIncoming({ maxChatId: '100', maxMessageId: 7, time: 2000, text: '👤 Петя:\nфото', mediaIds: [55, 56] });
bc.copyOwner('100', 8, 3000, { message_id: 10, text: 'ок' } as unknown as Message);
await vi.waitFor(async () => expect((await store.get('b2'))!.cursor).toBe(3000));
await vi.waitFor(async () => expect((await store.get('b1'))!.cursor).toBe(3000));
const toPlace = (chatId: number) => bot.telegram.sendMessage.mock.results.filter((_r, i) => bot.telegram.sendMessage.mock.calls[i]![0] === chatId);
const firstIds = await Promise.all(toPlace(-500).map((r) => r.value as Promise<{ message_id: number }>));
expect(bot.telegram.pinChatMessage.mock.calls).toEqual(firstIds.map((m) => [-500, m.message_id, { disable_notification: true }]));
});
it('«📌» without the right to pin goes off and says so in the topic; copying goes on', async () => {
const { store, bot, bc } = await setup(broadcast({ pin: true }));
bot.telegram.pinChatMessage.mockRejectedValue(Object.assign(new Error('400'), { response: { error_code: 400, description: 'Bad Request: not enough rights to manage pinned messages in the chat' } }));
bc.copyIncoming({ maxChatId: '100', maxMessageId: 7, time: 2000, text: 'раз', mediaIds: [] });
await vi.waitFor(async () => expect((await store.get('b1'))!.cursor).toBe(2000));
expect((await store.get('b1'))!.pin).toBeUndefined();
expect(bot.telegram.sendMessage).toHaveBeenCalledWith('-1001', expect.stringMatching(/Закреп в «Читалка» выключен: у бота там нет права закреплять/), { message_thread_id: 77 });
bc.copyIncoming({ maxChatId: '100', maxMessageId: 8, time: 3000, text: 'два', mediaIds: [] });
await vi.waitFor(async () => expect((await store.get('b1'))!.cursor).toBe(3000));
expect(bot.telegram.pinChatMessage).toHaveBeenCalledTimes(1);
});
it('the «… закрепил» line goes only for the bot\'s own pin', async () => {
const { store, bot, bc } = await setup(broadcast({ pin: true }));
bc.copyIncoming({ maxChatId: '100', maxMessageId: 7, time: 2000, text: 'раз', mediaIds: [] });
await vi.waitFor(async () => expect((await store.get('b1'))!.cursor).toBe(2000));
const ours = bot.telegram.pinChatMessage.mock.calls[0]![1];
await bc.onTargetUpdate({ chat: { id: -500 }, message: { message_id: 300, pinned_message: { message_id: 999 } } } as never);
expect(bot.telegram.deleteMessage).not.toHaveBeenCalled();
await bc.onTargetUpdate({ chat: { id: -500 }, message: { message_id: 301, pinned_message: { message_id: ours } } } as never);
expect(bot.telegram.deleteMessage).toHaveBeenCalledWith(-500, 301);
// A channel's line arrives as a channel post.
bc.copyIncoming({ maxChatId: '100', maxMessageId: 8, time: 3000, text: 'два', mediaIds: [] });
await vi.waitFor(() => expect(bot.telegram.pinChatMessage).toHaveBeenCalledTimes(2));
await bc.onTargetUpdate({ chat: { id: -500 }, channelPost: { message_id: 302, pinned_message: { message_id: bot.telegram.pinChatMessage.mock.calls[1]![1] } } } as never);
expect(bot.telegram.deleteMessage).toHaveBeenLastCalledWith(-500, 302);
});
it('the «📌» button switches it on only where the bot may pin, and off again', async () => {
const { store, bot, bc } = await setup(broadcast());
bc.register();
const [, onPin] = bot.action.mock.calls.find((c) => String(c[0]).includes('pin'))! as [RegExp, (ctx: unknown) => Promise<void>];
const press = () => {
const ctx = { match: ['', 'b1'], from: { id: 9 }, answerCbQuery: vi.fn(async () => true), editMessageText: vi.fn(async () => true) };
return onPin(ctx).then(() => ctx);
};
let ctx = await press(); // a plain member
expect(ctx.answerCbQuery).toHaveBeenCalledWith(expect.stringMatching(/администратором с правом «Закрепление сообщений»/), { show_alert: true });
expect((await store.get('b1'))!.pin).toBeUndefined();
bot.telegram.getChatMember.mockResolvedValue({ status: 'administrator', can_pin_messages: true, can_delete_messages: false });
ctx = await press();
expect((await store.get('b1'))!.pin).toBe(true);
expect(ctx.answerCbQuery).toHaveBeenCalledWith(expect.stringMatching(/закрепил» останется/), { show_alert: true });
expect(ctx.editMessageText).toHaveBeenCalledWith(expect.stringMatching(/📌 закрепляются/), expect.anything());
ctx = await press();
expect((await store.get('b1'))!.pin).toBeUndefined();
expect(ctx.answerCbQuery).toHaveBeenCalledWith('📌 Закреп выключен');
});
});
describe('switching a broadcast on', () => {