mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 10:23:24 +00:00
1.3.8-beta.11: трансляция умеет закреплять — кнопка «📌 Закреп» в /broadcast у каждого места: первое сообщение каждой копии закрепляется тихо (без уведомлений), твои сообщения из ТГ тоже (просьба через бот-репорт, 2026-10-15); без права закреплять закреп выключается сам и тема говорит почему; строка «… закрепил» от своего закрепа удаляется (нужно право удалять; channel_post включён ради каналов, чужие посты каналов игнорируются); ссылки «👥 В группу» / «📢 В канал» сразу просят права закреплять и удалять
This commit is contained in:
parent
b6e282576a
commit
932db96f14
8 changed files with 202 additions and 21 deletions
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue