1.3.5-beta.3: трансляция — /broadcast в теме копирует новые сообщения чата MAX в любые группы и каналы Telegram (сколько угодно мест на чат); место выбирается штатным выбором чата Telegram в личке бота; текст заново под именем владельца вместо «Вы», вложения copyMessage из темы, ваши сообщения из ТГ тоже; правки и удаления повторяются; курсор на трансляцию не даёт переливать историю; бот молчит в группах трансляций и на служебные сообщения о своём добавлении; трансляция сама останавливается, если бота убрали

This commit is contained in:
Folist 2026-10-12 23:48:54 +03:00
parent 3589881e87
commit 7f9f7c5ec2
8 changed files with 792 additions and 6 deletions

View file

@ -17,6 +17,7 @@
- Удаление сообщений в обе стороны; удаление чата в MAX удаляет и его тему в Telegram
- Ответы на сообщения (reply) — нативные в обе стороны, с переходом к оригиналу
- Пересылка сообщений — обычным drag-forward в Telegram, с автоматической пометкой источника
- 📡 Трансляция: новые сообщения чата MAX копируются в любые группы и каналы Telegram — для тех, у кого нет MAX (`/broadcast`, см. [«Трансляция»](#трансляция))
- 🎛 Пульт управления (`/panel`, закреплён в теме группы): поиск контакта MAX по номеру, нику или MAX ID и старт чата в один тап, пауза/возобновление MAX, бан/разбан чатов, вход в MAX, обновление — всё кнопками
- Управление MAX-группами прямо из Telegram: создание, приглашение/удаление участников, переименование, описание, выход, удаление
- Уведомления о звонках (входящий/завершённый/пропущенный) — только текстом, без передачи аудио
@ -149,6 +150,16 @@ FILES_PUBLIC_URL= # адрес за вашим веб-сервером, н
FILES_LISTEN= # локальный порт службы для вашего веб-сервера, например 127.0.0.1:3300
```
## Трансляция
Новые сообщения чата MAX можно копировать в обычные группы и каналы Telegram — для тех, у кого MAX нет. Например, чат садового товарищества — в канал, который читают соседи.
- **Включить.** Напишите `/broadcast` в теме чата. Кнопка «📡 Выбрать группу или канал» откроет личку с ботом, там внизу появятся «👥 Выбрать группу» и «📢 Выбрать канал» — штатный выбор Telegram из ваших групп и каналов. Бот должен там быть: в группе — участником, в канале — администратором с правом публиковать; Telegram предлагает добавить его сам.
- **Мест — сколько угодно.** Один чат можно транслировать в несколько групп и каналов, а в одну группу — несколько чатов. `/broadcast` в теме показывает, куда идёт трансляция, и останавливает любую кнопкой «⏹».
- **Что копируется.** Новые сообщения с момента включения: текст, фото, файлы, голосовые, с подписью автора, как в теме. Вместо «🧑 Вы» стоит ваше имя из MAX, ваши сообщения, написанные в теме из Telegram, тоже идут туда. Правки и удаления в MAX повторяются, пока мост не перезапускался. Опросы — текстом: голоса из Telegram в MAX не попадут. История до включения не переносится, сообщения, пришедшие пока мост был выключен, — догоняются.
- **В одну сторону.** Что пишут в группе трансляции, в MAX не уходит — бот там молчит. Чтобы участники только читали, выберите канал или закройте отправку сообщений в настройках группы.
- Если бота удалят из группы или канала, трансляция туда остановится сама, а в теме чата появится сообщение.
## Безопасность
Бот отвечает только внутри той Telegram-группы, что указана в `TARGET_TELEGRAM_GROUP` — любое сообщение или нажатие кнопки из другого чата (личка боту, добавление в чужую группу и т.п.) игнорируется.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "telemax",
"version": "1.3.5-beta.2",
"version": "1.3.5-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "telemax",
"version": "1.3.5-beta.2",
"version": "1.3.5-beta.3",
"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.5-beta.2",
"version": "1.3.5-beta.3",
"type": "module",
"scripts": {
"dev": "tsx watch src/server/app.ts",

438
src/bridge/broadcast.ts Normal file
View file

@ -0,0 +1,438 @@
import { randomBytes, randomInt } from 'node:crypto';
import { Markup, type Context, type Telegraf } from 'telegraf';
import type { ChatAdministratorRights, InlineKeyboardButton, Message, MessageEntity } from 'telegraf/types';
import type { Broadcast, BroadcastStore } from '../store/broadcastStore.js';
import { createLogger } from '../logger.js';
import { splitTelegramText, truncateCodePoints, truncateUtf16, TELEGRAM_CAPTION_LIMIT, TELEGRAM_TEXT_LIMIT } from './text.js';
import { withFloodRetry } from './transient.js';
// «Трансляция»: new messages of one MAX chat copied into any number of Telegram groups or channels,
// for people without MAX (asked for via the bug-report bot, 2026-10-12). One-way: what readers
// write there goes nowhere. Text is sent anew (the topic's text is plain — no entities to keep) so
// «🧑 Вы:» can name the owner; attachments are copied from the topic (copyMessage), never re-uploaded.
// Edits and deletions follow while the bridge runs (the copy map is in memory, like the topic's).
const logger = createLogger('broadcast');
/** A MAX message the topic just received, as the broadcast needs it. */
export interface IncomingCopy {
maxChatId: string;
maxMessageId: unknown;
/** MAX time of the message (ms) — compared with each broadcast's cursor. */
time: number;
/** The text as the topic shows it («🧑 Вы:» / «👤 Имя:» first); undefined for media only. */
text?: string;
/** Topic messages copied as they are: the attachments (not the text pieces, not a lone keyboard). */
mediaIds: number[];
/** A reply the topic shows natively — the quoted MAX message and its text. */
quote?: { maxMessageId: unknown; text: string };
}
export interface BroadcastDeps {
bot: Telegraf;
targetGroupId: string;
store: BroadcastStore;
/** The MAX chat's name — the card and the notices name it. */
chatTitle: (maxChatId: string) => Promise<string>;
/** The MAX chat behind a topic — /broadcast is written in it. */
chatOfTopic: (topicId: number) => Promise<string | undefined>;
/** The MAX chat's topic — where «включена / остановлена» notices go. */
topicOf: (maxChatId: string) => Promise<number | undefined>;
/** The MAX account owner's name — «🧑 Вы:» means nothing to readers. */
ownerName: () => Promise<string>;
isGroupAdmin: (userId: number) => Promise<boolean>;
}
export interface Broadcasts {
/** A MAX message landed in its topic (live or catch-up). */
copyIncoming(c: IncomingCopy): void;
/** The owner wrote in the topic from Telegram and it went to MAX. */
copyOwner(maxChatId: string, maxMessageId: unknown, time: number, msg: Message, quote?: { maxMessageId: unknown; text: string }, lead?: string): void;
/** MAX edited a message — `marked` is the topic's new text (edit marker included). */
copyEdit(maxChatId: string, maxMessageId: unknown, marked: string): void;
/** MAX deleted a message. */
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>;
/** The DM part of switching one on (deep link, chat picker); true when the update was ours. */
handlePrivate(ctx: Context): Promise<boolean>;
/** /broadcast and the card's buttons — registered after the bridge's admin gate. */
register(): void;
}
/** The topic's text for readers: «🧑 Вы:» (the bridge owner) becomes the owner's name, an edit marker in front included. */
export function readerText(text: string, owner: string): string {
return text.replace(/^(✏️ \(\d{1,2}:\d{2}\) )?🧑 Вы:\n/, (_m, edit: string | undefined) => `${edit ?? ''}🧑 ${owner}:\n`);
}
/** A reply whose quoted message has no copy here: «↩️ В ответ на «…»:» goes under the author line. */
export function withQuote(text: string | undefined, quoted: string): string {
const snippet = quoted ? `«${truncateCodePoints(quoted, 80, '…')}»` : 'сообщение';
const line = `↩️ В ответ на ${snippet}:`;
if (!text) return line;
const author = /^(?:🧑|👤) [^\n]*:\n/.exec(text);
return author ? `${author[0]}${line}\n${text.slice(author[0].length)}` : `${line}\n${text}`;
}
/** Entities of a text that got `by` UTF-16 units put in front of it. */
export function shiftEntities(entities: MessageEntity[] | undefined, by: number): MessageEntity[] | undefined {
return entities?.map((e) => ({ ...e, offset: e.offset + by }));
}
/** Telegram says the bot can't post there any more: kicked, left, demoted, the chat gone. */
export function isGoneError(err: unknown): boolean {
const r = (err as { response?: { error_code?: number; description?: string } } | null)?.response;
if (r?.error_code === 403) return true;
return /chat not found|not enough rights|need administrator rights|CHAT_WRITE_FORBIDDEN|CHAT_ADMIN_REQUIRED|have no rights|bot is not a member|bot was kicked/i.test(r?.description ?? '');
}
/** The /broadcast card for a MAX chat: its broadcasts with «⏹», a deep link to add one. */
export function broadcastCard(
chatTitle: string,
list: Broadcast[],
addLink: string | undefined,
): { text: string; markup: { inline_keyboard: InlineKeyboardButton[][] } } {
const day = (ms: number) => new Date(ms).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', timeZone: process.env.TZ || 'Europe/Moscow' });
const where = (b: Broadcast) => `${b.targetType === 'channel' ? '📢' : '👥'} ${b.targetTitle}`;
const lines = [`📡 Трансляция чата «${chatTitle}»`];
if (list.length === 0) {
lines.push(
'Новые сообщения этого чата можно копировать в группы и каналы Telegram — для тех, у кого нет MAX. Мест — сколько угодно. История до включения не переносится, а что пишут там — в MAX не уходит.',
'',
'Нажмите кнопку — откроется личка с ботом, там выберите группу или канал.',
);
} else {
lines.push('Новые сообщения копируются сюда:', ...list.map((b) => `• ${where(b)} — с ${day(b.since)}`));
}
if (!addLink) lines.push('', '⚠️ Бот ещё не знает своё имя — повторите /broadcast через минуту.');
const rows: InlineKeyboardButton[][] = list.map((b) => [Markup.button.callback(`⏹ Остановить: ${truncateCodePoints(b.targetTitle, 40, '…')}`, `tlmx_bc:stop:${b.id}`)]);
if (addLink) rows.push([Markup.button.url(list.length ? '➕ Ещё группа или канал' : '📡 Выбрать группу или канал', addLink)]);
rows.push([Markup.button.callback('◀️ Готово', 'tlmx_panel:dismiss')]);
return { text: lines.join('\n'), markup: { inline_keyboard: rows } };
}
/** What a channel's admin rights must include for the bot (and so for the user who picks it). */
const CHANNEL_RIGHTS: ChatAdministratorRights = {
is_anonymous: false,
can_manage_chat: false,
can_delete_messages: false,
can_manage_video_chats: false,
can_restrict_members: false,
can_promote_members: false,
can_change_info: false,
can_invite_users: false,
can_post_messages: true,
can_edit_messages: true,
};
const LINK_TTL_MS = 30 * 60_000;
const COPY_MAP_CAP = 1000;
const CANCEL = '✖️ Отмена';
type CopyLink = { broadcastId: string; targetChatId: number; ids: number[]; anchor?: number };
type PickRequest = { maxChatId: string; requesterId: number; at: number };
export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
const { bot, targetGroupId, store } = deps;
/** `${maxChatId}:${maxMessageId}` → the copies of that message in every broadcast. */
const copies = new Map<string, CopyLink[]>();
/** One chain per broadcast — copies keep the chat's order. */
const queues = new Map<string, Promise<void>>();
/** Deep-link codes from /broadcast cards and the picker requests they open in the DM. */
const codes = new Map<string, PickRequest>();
const requests = new Map<number, PickRequest & { kind: 'group' | 'channel' }>();
const keyOf = (maxChatId: string, maxMessageId: unknown) => `${maxChatId}:${String(maxMessageId)}`;
function remember(key: string, link: CopyLink): void {
if (!copies.has(key) && copies.size >= COPY_MAP_CAP) {
const oldest = copies.keys().next().value;
if (oldest !== undefined) copies.delete(oldest);
}
copies.set(key, [...(copies.get(key) ?? []), link]);
}
function enqueue(id: string, job: () => Promise<void>): void {
const run = (queues.get(id) ?? Promise.resolve()).then(job);
queues.set(id, run.catch(() => undefined));
}
async function notifyTopic(maxChatId: string, text: string): Promise<void> {
const topicId = await deps.topicOf(maxChatId);
if (topicId == null) return;
await bot.telegram.sendMessage(targetGroupId, text, { message_thread_id: topicId }).catch((err) => logger.error('Broadcast notice to the topic failed', err));
}
/** A send into the broadcast failed: gone → the broadcast stops and the topic says why; anything else is logged. */
async function failed(b: Broadcast, err: unknown, what: string): Promise<void> {
if (!isGoneError(err)) {
logger.error(`Broadcast ${b.id} (MAX chat ${b.maxChatId} -> ${b.targetChatId}): ${what} failed`, err);
return;
}
if (!(await store.remove(b.id))) return;
logger.info(`Broadcast ${b.id} stopped: Telegram refuses posts to ${b.targetChatId} (${(err as Error).message})`);
await notifyTopic(
b.maxChatId,
`⏹ Трансляция в «${b.targetTitle}» остановлена: бота убрали ${b.targetType === 'channel' ? 'из канала или лишили права публиковать' : 'из группы или запретили ему писать'}. Включить снова — /broadcast.`,
);
}
/** Runs `send` for every broadcast of the chat that hasn't seen `time` yet, in that broadcast's order. */
async function fanOut(maxChatId: string, time: number, what: string, send: (b: Broadcast) => Promise<void>): Promise<void> {
if (!Number.isFinite(time)) time = Date.now(); // a cursor must never become NaN
for (const b of await store.forChat(maxChatId)) {
if (time <= b.cursor) continue;
enqueue(b.id, async () => {
const fresh = await store.get(b.id);
if (!fresh || time <= fresh.cursor) return; // stopped, or a catch-up already copied it
try {
await send(fresh);
} catch (err) {
await failed(fresh, err, what);
}
await store.advance(fresh.id, time);
});
}
}
/** 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;
const link = copies.get(keyOf(maxChatId, quote.maxMessageId))?.find((l) => l.broadcastId === b.id);
return link ? (link.anchor ?? link.ids[0]) : undefined;
}
const replyExtra = (id: number | undefined) => (id ? { reply_parameters: { message_id: id, allow_sending_without_reply: true } } : {});
function copyIncoming(c: IncomingCopy): void {
void fanOut(c.maxChatId, c.time, `copy of MAX message ${String(c.maxMessageId)}`, async (b) => {
const ids: number[] = [];
let anchor: number | undefined;
try {
let text = c.text != null ? readerText(c.text, await deps.ownerName()) : undefined;
const reply = replyTo(b, c.maxChatId, c.quote);
if (c.quote && !reply) text = withQuote(text, c.quote.text);
if (text?.trim()) {
for (const [i, piece] of splitTelegramText(text).entries()) {
const m = await withFloodRetry(() => bot.telegram.sendMessage(b.targetChatId, piece, i === 0 ? replyExtra(reply) : {}));
ids.push(m.message_id);
anchor ??= m.message_id;
}
}
for (const id of c.mediaIds) {
const m = await withFloodRetry(() => bot.telegram.copyMessage(b.targetChatId, targetGroupId, id, ids.length === 0 ? replyExtra(reply) : {}));
ids.push(m.message_id);
}
} finally {
if (ids.length) remember(keyOf(c.maxChatId, c.maxMessageId), { broadcastId: b.id, targetChatId: b.targetChatId, ids, anchor });
}
});
}
/** `lead`: a line under the name — «↩️ Переслано от …» for a forward (a copy carries no forward header). */
function copyOwner(maxChatId: string, maxMessageId: unknown, time: number, msg: Message, quote?: { maxMessageId: unknown; text: string }, lead?: string): void {
void fanOut(maxChatId, time, `copy of the owner's message ${String(maxMessageId)}`, async (b) => {
const ids: number[] = [];
let anchor: number | undefined;
try {
let prefix = `🧑 ${await deps.ownerName()}:\n${lead ? `${lead}\n` : ''}`;
const reply = replyTo(b, maxChatId, quote);
if (quote && !reply) prefix = withQuote(prefix, quote.text);
const send = async (text: string, extra: object = {}) => {
const m = await withFloodRetry(() => bot.telegram.sendMessage(b.targetChatId, text, { ...(ids.length === 0 ? replyExtra(reply) : {}), ...extra }));
ids.push(m.message_id);
anchor ??= m.message_id;
};
const copy = async (extra: object = {}) => {
const m = await withFloodRetry(() => bot.telegram.copyMessage(b.targetChatId, targetGroupId, msg.message_id, { ...(ids.length === 0 ? replyExtra(reply) : {}), ...extra }));
ids.push(m.message_id);
anchor ??= m.message_id;
};
if ('text' in msg) {
const full = prefix + msg.text;
if (full.length <= TELEGRAM_TEXT_LIMIT) await send(full, { entities: shiftEntities(msg.entities, prefix.length) });
else {
await send(prefix.trimEnd());
await copy();
}
} else if ('poll' in msg) {
// A copied poll would collect votes MAX never sees — readers get it as text.
await send(`${prefix}📊 ${msg.poll.question}\n${msg.poll.options.map((o) => `• ${o.text}`).join('\n')}`);
} else if ('photo' in msg || 'video' in msg || 'document' in msg || 'audio' in msg || 'animation' in msg || 'voice' in msg) {
const own = 'caption' in msg ? (msg.caption ?? '') : '';
const entities = 'caption_entities' in msg ? msg.caption_entities : undefined;
if ((prefix + own).length <= TELEGRAM_CAPTION_LIMIT) await copy({ caption: prefix + own, caption_entities: shiftEntities(entities, prefix.length) });
else {
await send(prefix.trimEnd());
await copy();
}
} else {
// Sticker, video note, location, contact — no caption: the name goes first.
await send(prefix.trimEnd());
await copy();
}
} finally {
if (ids.length) remember(keyOf(maxChatId, maxMessageId), { broadcastId: b.id, targetChatId: b.targetChatId, ids, anchor });
}
});
}
function copyEdit(maxChatId: string, maxMessageId: unknown, marked: string): void {
for (const link of copies.get(keyOf(maxChatId, maxMessageId)) ?? []) {
if (link.anchor == null) continue;
enqueue(link.broadcastId, async () => {
let text = readerText(marked, await deps.ownerName());
if (text.length > TELEGRAM_TEXT_LIMIT) text = `${truncateUtf16(text, TELEGRAM_TEXT_LIMIT - 1)}…`;
await bot.telegram.editMessageText(link.targetChatId, link.anchor, undefined, text).catch((err) => {
if (!/not modified/i.test((err as Error).message)) logger.error(`Broadcast ${link.broadcastId}: edit of MAX message ${String(maxMessageId)} failed`, err);
});
});
}
}
function copyDelete(maxChatId: string, maxMessageId: unknown): void {
const key = keyOf(maxChatId, maxMessageId);
for (const link of copies.get(key) ?? []) {
enqueue(link.broadcastId, async () => {
await bot.telegram.deleteMessages(link.targetChatId, link.ids).catch((err) => logger.error(`Broadcast ${link.broadcastId}: deletion of MAX message ${String(maxMessageId)} failed`, err));
});
}
copies.delete(key);
}
// --- Switching one on: /broadcast card → deep link → the DM's chat picker ----------------------
function deepLink(maxChatId: string, requesterId: number): string | undefined {
const username = bot.botInfo?.username;
if (!username) return undefined;
for (const [code, p] of codes) if (Date.now() - p.at > LINK_TTL_MS) codes.delete(code);
const code = randomBytes(9).toString('base64url');
codes.set(code, { maxChatId, requesterId, at: Date.now() });
return `https://t.me/${username}?start=bc_${code}`;
}
async function card(maxChatId: string, requesterId: number) {
return broadcastCard(await deps.chatTitle(maxChatId), await store.forChat(maxChatId), deepLink(maxChatId, requesterId));
}
const removeKeyboard = { reply_markup: { remove_keyboard: true as const } };
async function handlePrivate(ctx: Context): Promise<boolean> {
const msg = ctx.message;
const userId = ctx.from?.id;
if (!msg || userId == null) return false;
const start = 'text' in msg ? /^\/start bc_([\w-]+)$/.exec(msg.text) : null;
if (start) {
const p = codes.get(start[1]!);
if (!p || Date.now() - p.at > LINK_TTL_MS) {
await ctx.reply('Ссылка устарела — напишите /broadcast в теме чата ещё раз.');
return true;
}
if (p.requesterId !== userId || !(await deps.isGroupAdmin(userId).catch(() => false))) {
await ctx.reply('Эта ссылка открывает трансляцию только тому администратору, который её запросил.');
return true;
}
for (const [id, r] of requests) if (r.requesterId === userId || Date.now() - r.at > LINK_TTL_MS) requests.delete(id);
const groupReq = randomInt(1, 2 ** 31 - 1);
const channelReq = randomInt(1, 2 ** 31 - 1);
requests.set(groupReq, { ...p, at: Date.now(), kind: 'group' });
requests.set(channelReq, { ...p, at: Date.now(), kind: 'channel' });
const title = await deps.chatTitle(p.maxChatId);
await ctx.reply(
`📡 Куда транслировать чат «${title}»?\n\nНажмите кнопку внизу и выберите группу или канал из своих. Бот должен там быть: для канала — администратором с правом публиковать, Telegram предложит добавить его сам.`,
Markup.keyboard([
[Markup.button.groupRequest('👥 Выбрать группу', groupReq, { bot_is_member: true })],
[Markup.button.channelRequest('📢 Выбрать канал', channelReq, { bot_administrator_rights: CHANNEL_RIGHTS, user_administrator_rights: CHANNEL_RIGHTS })],
[CANCEL],
])
.resize()
.oneTime(),
);
return true;
}
if ('chat_shared' in msg) {
const r = requests.get(msg.chat_shared.request_id);
if (!r || r.requesterId !== userId) {
await ctx.reply('Этот выбор устарел — напишите /broadcast в теме чата ещё раз.', removeKeyboard);
return true;
}
const chatId = msg.chat_shared.chat_id;
const botId = bot.botInfo?.id;
const member = botId != null ? await bot.telegram.getChatMember(chatId, botId).catch(() => undefined) : undefined;
const canPost =
r.kind === 'channel'
? member?.status === 'creator' || (member?.status === 'administrator' && member.can_post_messages === true)
: member?.status === 'member' || member?.status === 'administrator' || member?.status === 'creator' || (member?.status === 'restricted' && member.can_send_messages);
if (!canPost) {
const who = bot.botInfo?.username ? `@${bot.botInfo.username}` : 'бота';
await ctx.reply(
r.kind === 'channel'
? `Бот не администратор этого канала или ему нельзя публиковать. Добавьте ${who} в администраторы канала с правом «Публикация сообщений» и выберите канал ещё раз.`
: `Бота нет в этой группе или ему запрещено писать. Добавьте ${who} в группу и выберите её ещё раз.`,
);
return true;
}
if (String(chatId) === targetGroupId) {
await ctx.reply('Это группа самого моста — выберите другую.');
return true;
}
if ((await store.forChat(r.maxChatId)).some((b) => b.targetChatId === chatId)) {
await ctx.reply('Этот чат уже транслируется туда.', removeKeyboard);
return true;
}
const chat = await bot.telegram.getChat(chatId).catch(() => undefined);
const targetTitle = (chat && 'title' in chat ? chat.title : undefined) ?? String(chatId);
const title = await deps.chatTitle(r.maxChatId);
const now = Date.now();
await store.add({ id: randomBytes(6).toString('base64url'), maxChatId: r.maxChatId, targetChatId: chatId, targetTitle, targetType: r.kind, since: now, cursor: now });
for (const [id, x] of requests) if (x.requesterId === userId) requests.delete(id);
logger.info(`Broadcast on: MAX chat ${r.maxChatId} -> ${r.kind} ${chatId}`);
await bot.telegram
.sendMessage(chatId, `📡 Сюда транслируется чат «${title}» из MAX — новые сообщения будут появляться здесь.`)
.catch((err) => logger.error('Broadcast hello to the target failed', err));
await notifyTopic(r.maxChatId, `📡 Трансляция в «${targetTitle}» включена — новые сообщения этого чата копируются туда. Управление — /broadcast.`);
await ctx.reply(`✅ Готово: чат «${title}» транслируется в «${targetTitle}». Остановить — /broadcast в теме чата.`, removeKeyboard);
return true;
}
if ('text' in msg && msg.text === CANCEL && [...requests.values()].some((r) => r.requesterId === userId)) {
for (const [id, r] of requests) if (r.requesterId === userId) requests.delete(id);
await ctx.reply('Отменено.', removeKeyboard);
return true;
}
return false;
}
function register(): void {
bot.command('broadcast', async (ctx) => {
const topicId = ctx.message.message_thread_id;
const userId = ctx.from?.id;
const maxChatId = topicId != null ? await deps.chatOfTopic(topicId) : undefined;
if (!maxChatId || userId == null) {
await ctx.reply('Напишите /broadcast в теме того чата MAX, который нужно транслировать.').catch(() => {});
return;
}
const { text, markup } = await card(maxChatId, userId);
await ctx.reply(text, { message_thread_id: topicId, reply_markup: markup });
});
bot.action(/^tlmx_bc:stop:([\w-]+)$/, async (ctx) => {
const gone = await store.remove(ctx.match[1]!);
await ctx.answerCbQuery(gone ? 'Трансляция остановлена' : 'Уже остановлена').catch(() => {});
if (gone) {
logger.info(`Broadcast off: MAX chat ${gone.maxChatId} -> ${gone.targetChatId}`);
await bot.telegram.sendMessage(gone.targetChatId, '⏹ Трансляция остановлена.').catch(() => {});
}
const topicId = (ctx.callbackQuery.message as { message_thread_id?: number } | undefined)?.message_thread_id;
const maxChatId = gone?.maxChatId ?? (topicId != null ? await deps.chatOfTopic(topicId) : undefined);
if (!maxChatId) return;
const { text, markup } = await card(maxChatId, ctx.from.id);
await ctx.editMessageText(text, { reply_markup: markup }).catch(() => {});
});
}
return {
copyIncoming,
copyOwner,
copyEdit,
copyDelete,
isTarget: (chatId) => store.isTarget(chatId),
handlePrivate,
register,
};
}

View file

@ -2,7 +2,7 @@ import path from 'node:path';
import { rm, stat, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { Markup, type Context, type Telegraf } from 'telegraf';
import type { ChatAction, TelegramEmoji } from 'telegraf/types';
import type { ChatAction, Message, TelegramEmoji } from 'telegraf/types';
import { CONTACT_FLAG_ADDED, isAddedContact, type MaxClient, type MaxContactInfo, type MaxMessageEvent, type MaxHistoryMessage } from '../max/client.js';
import { OPCODES, formatOpcode } from '../max/opcodes.js';
import { resolveContactDisplayName, resolveChatName, isFallbackTitle, type ContactProfile } from '../max/names.js';
@ -32,6 +32,8 @@ import { addToMaxContacts, profileName, wireControlPanel, type PauseControl } fr
import { keepPanelBack } from './panelBack.js';
import { createBugReports, isBugReportInboxEnabled, BUGREPORT_BOT_HANDLE, type BugReports } from './bugReports.js';
import { createMaxAuthFlow, type MaxAuthCallbacks } from './maxAuthFlow.js';
import { createBroadcasts, type IncomingCopy } from './broadcast.js';
import { BroadcastStore } from '../store/broadcastStore.js';
import { createTelemetry } from './telemetry.js';
import { checkVersion, type VersionStatus } from './version.js';
import { buildStatusText, collectHostStats, maskPhone } from './status.js';
@ -929,6 +931,8 @@ export interface ChatCatchUpDeps {
strikes?: StrikeCounter;
/** Pause after each backfilled message (HISTORY_SEND_DELAY_MS) — injectable for tests. */
paceMs?: number;
/** A message the catch-up put into its topic — «трансляция» copies it on (broadcast.ts; its cursor drops replays). */
onRelayed?: (c: IncomingCopy) => void;
}
/**
@ -1190,12 +1194,17 @@ export class ChatCatchUp {
// withFloodRetry lives INSIDE sendAttachments, around each single send (see the NOTE there).
attachIds = await sendAttachments(bot, groupId, topicId, attaches, downloadCtx, undefined, paceMs, sent, lastTry);
}
// A broadcast copies the attachments, not a lone keyboard message.
const mediaIds = [...attachIds];
if (keyboard && textIds.length === 0) {
phase = 'text';
attachIds.push(...(await sendKeyboardAlone(bot, groupId, topicId, keyboard, { paceMs, sent, maxMessageId: msg.id })));
}
const linkIds = buildLinkIds(textIds, attachIds);
if (linkIds && msg.id != null) messageLinks.add({ maxChatId: chatId, maxMessageId: msg.id, ...linkIds });
if (linkIds && msg.id != null) {
messageLinks.add({ maxChatId: chatId, maxMessageId: msg.id, ...linkIds });
this.deps.onRelayed?.({ maxChatId: String(chatId), maxMessageId: msg.id, time: Number(msg.time), text, mediaIds });
}
this.strikes.clear(abortKey);
} catch (err) {
if (isThreadNotFound(err)) throw err;
@ -1476,6 +1485,21 @@ export function wireBridge({
// In-Telegram MAX (re)authorization: a private /login conversation (SMS code + optional 2FA
// password) — the only login path. In DM so code/password stay private, and admin-gated.
const maxAuth = createMaxAuthFlow({ targetGroupId, auth });
// «Трансляция» (broadcast.ts): new messages of a MAX chat copied into Telegram groups and channels.
const broadcasts = createBroadcasts({
bot,
targetGroupId,
store: new BroadcastStore(),
chatTitle: async (maxChatId) => resolveTopicTitle(maxChatId) ?? (await chatMapStore.getByMaxChatId(maxChatId))?.title ?? 'Чат',
topicOf: async (maxChatId) => (await chatMapStore.getByMaxChatId(maxChatId))?.telegramTopicId,
chatOfTopic: async (topicId) => (await chatMapStore.getByTopicId(topicId))?.maxChatId,
ownerName: async () => {
const myId = getMyAccountId();
const profile = myId != null ? await resolveProfile(max, myId, 'the account owner', getContactProfiles()) : undefined;
return myId != null && profile ? resolveContactDisplayName(myId, profile) : 'Владелец';
},
isGroupAdmin: (userId) => isGroupAdmin(userId),
});
// A button press answered after Telegram's ~15 s window (it waited out a restart) used to throw
// out of every handler that starts with `await ctx.answerCbQuery()` — the action never ran and the
@ -1501,11 +1525,17 @@ export function wireBridge({
if (ctx.chat && String(ctx.chat.id) !== targetGroupId) {
// A private DM from an outsider is a bug report (or a redirect); only GROUPS get the hard reject.
if (ctx.chat.type === 'private') {
// The /login auth flow gets first refusal (it admin-gates internally).
// A broadcast's chat picker, then the /login auth flow get first refusal (both admin-gate internally).
if (await broadcasts.handlePrivate(ctx)) return;
if (await maxAuth.handlePrivate(ctx)) return;
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;
const m = ctx.message as { new_chat_members?: unknown; left_chat_member?: unknown; group_chat_created?: unknown; supergroup_chat_created?: unknown; migrate_to_chat_id?: unknown; migrate_from_chat_id?: unknown } | undefined;
if (m && (m.new_chat_members || m.left_chat_member || m.group_chat_created || m.supergroup_chat_created || m.migrate_to_chat_id || m.migrate_from_chat_id)) return;
if (ctx.callbackQuery) {
ctx.answerCbQuery('Не вы меня создали.').catch(() => {});
} else {
@ -1574,6 +1604,7 @@ export function wireBridge({
sendCard: (chatId, topicId, sender) => sendAutoInfoCard(chatId, sender, topicId),
forgetChatLinks,
isWiping: () => wiping,
onRelayed: (c) => broadcasts.copyIncoming(c),
});
/**
@ -1971,6 +2002,7 @@ export function wireBridge({
return;
}
for (const messageId of p.messageIds) {
broadcasts.copyDelete(String(p.chatId), messageId);
const link = messageLinks.getByMax(p.chatId, messageId);
if (!link) continue;
await dropLinkedMessage(link, `MAX NOTIF_MSG_DELETE of message ${String(messageId)}`);
@ -2192,6 +2224,7 @@ export function wireBridge({
await dropLinkedMessage(link, `MAX deletion of message ${String(message.id)}`);
logger.info(`MAX -> TG: deletion of message ${String(message.id)} in chat ${String(chatId)} mirrored (${linkTelegramIds(link).length} Telegram message(s) removed)`);
}
broadcasts.copyDelete(String(chatId), message.id);
return;
}
// Everything below writes into the chat's topic: open it and catch the chat up first
@ -2224,11 +2257,14 @@ export function wireBridge({
// `message`, the OUTGOING reply `messageId`). A NATIVE Telegram reply when the quoted message
// is in the link store; a text-quote prefix otherwise (the store is in-memory and bounded).
let replyParameters: { message_id: number; allow_sending_without_reply: boolean } | undefined;
// The same reply for a broadcast: a native reply there too when the quoted message was copied, else a quote line.
let broadcastQuote: IncomingCopy['quote'];
if (message.link?.type === 'REPLY') {
const quotedId = message.link.message?.id;
const linked = quotedId != null ? messageLinks.getByMax(chatId, quotedId) : undefined;
if (linked) {
replyParameters = { message_id: linked.telegramMessageId, allow_sending_without_reply: true };
broadcastQuote = { maxMessageId: quotedId, text: typeof message.link.message?.text === 'string' ? message.link.message.text : '' };
} else {
const quotedText = typeof message.link.message?.text === 'string' ? message.link.message.text : '';
// Cut by code points: a UTF-16 slice ending inside a surrogate pair gets the whole message refused.
@ -2277,6 +2313,7 @@ export function wireBridge({
} catch (err) {
await relayEditFallback(chatId, message.id, existingLink, marked, err);
}
broadcasts.copyEdit(String(chatId), message.id, marked);
}
const updatedPoll = attaches.find((a) => (a as MaxAttachment)._type === 'POLL') as MaxAttachment | undefined;
if (updatedPoll) {
@ -2409,6 +2446,9 @@ export function wireBridge({
}
if (linkIds && message.id != null) {
messageLinks.add({ maxChatId: chatId, maxMessageId: message.id, ...linkIds, ...(noticeIds.length > 0 ? { noticeTelegramMessageIds: noticeIds } : {}) });
// Readers get the poll as text: votes in a copied poll would never reach MAX.
const pollText = renderPollAsText(pollAttach.title, options, settings);
broadcasts.copyIncoming({ maxChatId: String(chatId), maxMessageId: message.id, time: Number(message.time), text: text ? `${text}\n${pollText}` : pollText, mediaIds: [], quote: broadcastQuote });
}
await chatSync.advanceCursor(chatId, message.time, 'incoming poll');
// A fresh Telegram poll starts at zero (no way to pre-seed a vote): a tally already
@ -2444,6 +2484,8 @@ export function wireBridge({
// The reply goes on the text when there is one; only a media-only reply threads it into the first attachment.
attachIds = await sendAttachments(bot, targetGroupId, topicId, attaches, downloadCtx, textIds.length > 0 ? undefined : replyParameters, undefined, sent);
}
// A broadcast copies the attachments, not a lone keyboard message.
const mediaIds = [...attachIds];
if (keyboard && textIds.length === 0) {
const reply = attachIds.length === 0 ? replyParameters : undefined;
attachIds.push(...(await sendKeyboardAlone(bot, targetGroupId, topicId, keyboard, { replyParameters: reply, sent, maxMessageId: message.id })));
@ -2451,6 +2493,8 @@ export function wireBridge({
const linkIds = buildLinkIds(textIds, attachIds);
if (linkIds) {
messageLinks.add({ maxChatId: chatId, maxMessageId: message.id, ...linkIds });
// An orphan edit is no new message — the broadcast has nothing to put it under.
if (!orphanEdit) broadcasts.copyIncoming({ maxChatId: String(chatId), maxMessageId: message.id, time: Number(message.time), text, mediaIds, quote: broadcastQuote });
lastInAt = Date.now();
logger.info(`MAX -> TG: message ${String(message.id)} of chat ${String(chatId)} -> topic ${topicId} (${linkTelegramIds(linkIds).length} Telegram message(s))`);
// Mirror image of rememberOutgoingSend: without it a reconnect's catch-up relays this
@ -2534,6 +2578,7 @@ export function wireBridge({
Сообщения, файлы, голосовые, стикеры и опросы синхронизируются в обе стороны автоматически — команды нужны только для управления. Обычная пересылка сообщений (drag-forward) в тему тоже работает сама — прилетит в привязанный MAX-чат с пометкой «↩️ Переслано от/из...». Звонки — только текстовые уведомления (входящий звонит / завершённый / пропущенный), без передачи аудио — для этого нужен WebRTC, вне рамок Bot API-моста.
/info — карточка контакта или чата (просто в теме). Кнопка «➕ В контакты MAX» сохраняет собеседника в контакты вашего MAX, в группе — выбранного участника
/broadcast — трансляция: новые сообщения этого чата копируются в выбранные группы и каналы Telegram — для тех, у кого нет MAX (в теме чата)
/file — отправить в этот чат MAX большой файл (до 4 ГБ) по ссылке: загружается один раз, мимо лимита Telegram в 20 МБ. Там же — файлы, которые уже лежат на сервере: нажмёте — уйдут в этот чат прямо с сервера
Файлообменник: /panel → 📁 Файлы → «📤 Загрузить файл». Положите на свой сервер файл любого размера — сколько вместит диск — и получите ссылку на скачивание, которой можно делиться где угодно. Файл хранится 3 дня от последней ссылки.
@ -2933,6 +2978,9 @@ export function wireBridge({
await sendContactInfoCard(bot, targetGroupId, topicId, chat?.title || mapping.title || 'Чат', chat?.type, count, otherId, profile, roster);
});
// /broadcast and its card's buttons — after the admin gate, like every command.
broadcasts.register();
/** «➕ В контакты MAX» from an /info card: the profile from the warm cache (or MAX), then the panel's addToMaxContacts — by phone under their MAX name, else by id. */
async function addParticipantToContacts(uid: number): Promise<{ ok: boolean; line: string; name: string }> {
const profiles = getContactProfiles();
@ -3602,11 +3650,18 @@ export function wireBridge({
return;
}
// What went to MAX goes on to the chat's broadcasts too, under the owner's name (broadcast.ts).
const quotedTelegram = replyToMessage as { text?: string; caption?: string } | undefined;
const ownerQuote = replyLink ? { maxMessageId: replyLink.messageId, text: quotedTelegram?.text ?? quotedTelegram?.caption ?? '' } : undefined;
const copyToBroadcasts = (messageId: unknown, time: unknown) =>
broadcasts.copyOwner(String(mapping.maxChatId), messageId, Number(time), ctx.message as Message, ownerQuote, forwardPrefix ?? undefined);
if (location) {
const locationAttach = { _type: 'LOCATION', latitude: location.latitude, longitude: location.longitude, zoom: 14 };
const { cid, messageId, time } = await max.sendMessage(mapping.maxChatId, null, [locationAttach]);
rememberOutgoingSend(mapping.maxChatId, cid, time);
messageLinks.add({ maxChatId: mapping.maxChatId, maxMessageId: messageId, telegramMessageId: ctx.message.message_id, telegramTopicId: topicId, outgoing: true });
copyToBroadcasts(messageId, time);
return;
}
@ -3625,6 +3680,7 @@ export function wireBridge({
const { cid, messageId, time } = await max.sendMessage(mapping.maxChatId, null, [contactAttach]);
rememberOutgoingSend(mapping.maxChatId, cid, time);
messageLinks.add({ maxChatId: mapping.maxChatId, maxMessageId: messageId, telegramMessageId: ctx.message.message_id, telegramTopicId: topicId, outgoing: true });
copyToBroadcasts(messageId, time);
return;
}
@ -3641,6 +3697,7 @@ export function wireBridge({
// Linked right away, not after the hint: a catch-up reaching the poll during that extra
// round trip would find no link and post it again.
messageLinks.add({ maxChatId: mapping.maxChatId, maxMessageId: messageId, telegramMessageId: ctx.message.message_id, telegramTopicId: topicId, outgoing: true });
copyToBroadcasts(messageId, time);
// Votes cast on THIS Telegram poll can't reach MAX: a bot gets poll_answer only for polls
// it sent itself (and never for anonymous ones) — say so instead of letting counts drift.
const hint = await bot.telegram
@ -3664,6 +3721,7 @@ export function wireBridge({
rememberOutgoingSend(mapping.maxChatId, cid, time);
const link: MessageLink = { maxChatId: mapping.maxChatId, maxMessageId: messageId, telegramMessageId: ctx.message.message_id, telegramTopicId: topicId, outgoing: true };
messageLinks.add(link);
copyToBroadcasts(messageId, time);
await sendMorePieces(mapping.maxChatId, morePieces, link);
return;
}
@ -3738,6 +3796,7 @@ export function wireBridge({
const { cid, messageId, time } = await max.sendMessage(mapping.maxChatId, caption, [attach], replyLink);
rememberOutgoingSend(mapping.maxChatId, cid, time);
messageLinks.add({ maxChatId: mapping.maxChatId, maxMessageId: messageId, telegramMessageId: ctx.message.message_id, telegramTopicId: topicId, outgoing: true });
copyToBroadcasts(messageId, time);
} catch (err) {
logger.error('Telegram -> MAX forward failed', err);
// A swallowed send is indistinguishable from success to the sender — report it in the topic.

View file

@ -457,6 +457,7 @@ const BOT_COMMANDS = [
{ command: 'version', description: 'Версия бота, обновление по кнопке' },
{ command: 'info', description: 'Карточка контакта/чата (просто в теме)' },
{ command: 'file', description: 'Большой файл в MAX по ссылке (в теме, до 4 ГБ)' },
{ command: 'broadcast', description: 'Трансляция чата в группы и каналы Telegram (в теме)' },
{ command: 'poll', description: 'Актуальный счёт опроса (ответом на сообщение)' },
{ command: 'delete', description: 'Удалить сообщение с обеих сторон (ответом)' },
{ command: 'newgroup', description: 'Создать группу в MAX: /newgroup <название>' },

100
src/store/broadcastStore.ts Normal file
View file

@ -0,0 +1,100 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
/** One «трансляция»: new messages of a MAX chat copied into a Telegram group or channel (bridge/broadcast.ts). */
export interface Broadcast {
/** Random id — names the broadcast in buttons (tlmx_bc:stop:<id>). */
id: string;
maxChatId: string;
/** The group or channel the copies go to. */
targetChatId: number;
targetTitle: string;
targetType: 'group' | 'channel';
/** Epoch ms it was switched on. */
since: number;
/** 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;
}
/**
* Persistent list of broadcasts, any number per MAX chat and per target. Plain JSON in
* .data/broadcasts.json — not secret. Same write-then-rename + serialized writes as BugReportStore.
*/
export class BroadcastStore {
private cache: Broadcast[] | null = null;
private writeQueue: Promise<void> = Promise.resolve();
private pendingLoad: Promise<Broadcast[]> | null = null;
constructor(private readonly filePath: string = path.join(process.cwd(), '.data', 'broadcasts.json')) {}
async all(): Promise<Broadcast[]> {
return [...(await this.load())];
}
async forChat(maxChatId: string): Promise<Broadcast[]> {
return (await this.load()).filter((b) => b.maxChatId === maxChatId);
}
async get(id: string): Promise<Broadcast | undefined> {
return (await this.load()).find((b) => b.id === id);
}
/** True when `chatId` receives at least one broadcast — the bridge then leaves that chat's updates alone. */
async isTarget(chatId: number): Promise<boolean> {
return (await this.load()).some((b) => b.targetChatId === chatId);
}
async add(b: Broadcast): Promise<void> {
const all = await this.load();
all.push(b);
await this.persist(all);
}
async remove(id: string): Promise<Broadcast | undefined> {
const all = await this.load();
const gone = all.find((b) => b.id === id);
if (!gone) return undefined;
this.cache = all.filter((b) => b.id !== id);
await this.persist(this.cache);
return gone;
}
/** 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);
if (!b || time <= b.cursor) return;
b.cursor = time;
await this.persist(this.cache!);
}
private load(): Promise<Broadcast[]> {
if (this.cache) return Promise.resolve(this.cache);
this.pendingLoad ??= (async () => {
try {
const raw = await readFile(this.filePath, 'utf8');
this.cache = JSON.parse(raw) as Broadcast[];
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') this.cache = [];
else throw err;
}
return this.cache;
})().finally(() => {
this.pendingLoad = null;
});
return this.pendingLoad;
}
private persist(list: Broadcast[]): Promise<void> {
const run = this.writeQueue.then(() => this.writeToDisk(list));
this.writeQueue = run.catch(() => undefined);
return run;
}
private async writeToDisk(list: Broadcast[]): Promise<void> {
await mkdir(path.dirname(this.filePath), { recursive: true });
const tmpPath = `${this.filePath}.tmp`;
await writeFile(tmpPath, JSON.stringify(list, null, 2), 'utf8');
await rename(tmpPath, this.filePath);
}
}

177
tests/broadcast.test.ts Normal file
View file

@ -0,0 +1,177 @@
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import type { Telegraf } from 'telegraf';
import type { Message } from 'telegraf/types';
import { broadcastCard, createBroadcasts, isGoneError, readerText, shiftEntities, withQuote } from '../src/bridge/broadcast.js';
import { BroadcastStore, type Broadcast } from '../src/store/broadcastStore.js';
const tempStore = async () => new BroadcastStore(path.join(await mkdtemp(path.join(tmpdir(), 'tlmx-bc-')), 'broadcasts.json'));
const broadcast = (over: Partial<Broadcast> = {}): Broadcast => ({
id: 'b1',
maxChatId: '100',
targetChatId: -500,
targetTitle: 'Читалка',
targetType: 'group',
since: 1000,
cursor: 1000,
...over,
});
describe('broadcast texts', () => {
it('names the owner instead of «Вы», an edit marker in front included', () => {
expect(readerText('🧑 Вы:\nпривет', 'Сергей')).toBe('🧑 Сергей:\nпривет');
expect(readerText('✏️ (12:05) 🧑 Вы:\nпоправил', 'Сергей')).toBe('✏️ (12:05) 🧑 Сергей:\nпоправил');
expect(readerText('👤 Петя:\nВы где?', 'Сергей')).toBe('👤 Петя:\nВы где?');
expect(readerText('просто текст про 🧑 Вы:\n', 'Сергей')).toBe('просто текст про 🧑 Вы:\n');
});
it('puts a quote line under the author line', () => {
expect(withQuote('👤 Петя:\nда', 'ты придёшь?')).toBe('👤 Петя:\n↩️ В ответ на «ты придёшь?»:\nда');
expect(withQuote('да', 'ты придёшь?')).toBe('↩️ В ответ на «ты придёшь?»:\nда');
expect(withQuote(undefined, '')).toBe('↩️ В ответ на сообщение:');
});
it('shifts entities by the prefix', () => {
expect(shiftEntities([{ type: 'bold', offset: 0, length: 3 }], 10)).toEqual([{ type: 'bold', offset: 10, length: 3 }]);
expect(shiftEntities(undefined, 10)).toBeUndefined();
});
it('tells a lost target from a passing failure', () => {
expect(isGoneError({ response: { error_code: 403, description: 'Forbidden: bot was kicked from the supergroup chat' } })).toBe(true);
expect(isGoneError({ response: { error_code: 400, description: 'Bad Request: chat not found' } })).toBe(true);
expect(isGoneError({ response: { error_code: 400, description: 'Bad Request: need administrator rights in the channel chat' } })).toBe(true);
expect(isGoneError({ response: { error_code: 400, description: 'Bad Request: message to copy not found' } })).toBe(false);
expect(isGoneError(new Error('socket hang up'))).toBe(false);
});
it('the card lists the places with a stop button each and offers one more', () => {
const empty = broadcastCard('Дача', [], 'https://t.me/bot?start=bc_x');
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' })], 'https://t.me/bot?start=bc_y');
expect(two.text).toMatch(/👥 Читалка/);
expect(two.text).toMatch(/📢 Новости/);
expect(two.markup.inline_keyboard.flat().map((b) => ('callback_data' in b ? b.callback_data : b.text))).toEqual([
'tlmx_bc:stop:b1',
'tlmx_bc:stop:b2',
'➕ Ещё группа или канал',
'tlmx_panel:dismiss',
]);
});
});
describe('BroadcastStore', () => {
it('keeps any number of places per chat, moves the cursor only forward, persists', async () => {
const store = await tempStore();
await store.add(broadcast());
await store.add(broadcast({ id: 'b2', targetChatId: -600 }));
expect((await store.forChat('100')).map((b) => b.id)).toEqual(['b1', 'b2']);
expect(await store.isTarget(-600)).toBe(true);
expect(await store.isTarget(-700)).toBe(false);
await store.advance('b1', 2000);
await store.advance('b1', 1500);
expect((await store.get('b1'))!.cursor).toBe(2000);
expect((await store.remove('b2'))!.id).toBe('b2');
expect(await store.remove('b2')).toBeUndefined();
const onDisk = JSON.parse(await readFile((store as unknown as { filePath: string }).filePath, 'utf8')) as Broadcast[];
expect(onDisk.map((b) => [b.id, b.cursor])).toEqual([['b1', 2000]]);
});
});
describe('copying into broadcasts', () => {
function fakeBot() {
let next = 1;
const telegram = {
sendMessage: vi.fn(async (_chatId: number | string, _text: string, _extra?: unknown) => ({ message_id: next++ })),
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),
};
return { telegram, botInfo: { id: 42, username: 'tlmx_bot' }, command: vi.fn(), action: vi.fn() };
}
async function setup(...list: Broadcast[]) {
const store = await tempStore();
for (const b of list) await store.add(b);
const bot = fakeBot();
const bc = createBroadcasts({
bot: bot as unknown as Telegraf,
targetGroupId: '-1001',
store,
chatTitle: async () => 'Дача',
chatOfTopic: async () => '100',
topicOf: async () => 77,
ownerName: async () => 'Сергей',
isGroupAdmin: async () => true,
});
return { store, bot, bc };
}
it('sends the text anew under the owner name and copies the attachments, to every place', async () => {
const { store, bot, bc } = await setup(broadcast(), broadcast({ id: 'b2', targetChatId: -600 }));
bc.copyIncoming({ maxChatId: '100', maxMessageId: 7, time: 2000, text: '🧑 Вы:\nфото с дачи', mediaIds: [55] });
await vi.waitFor(async () => expect((await store.get('b2'))!.cursor).toBe(2000));
expect(bot.telegram.sendMessage.mock.calls.map((c) => [c[0], c[1]])).toEqual([
[-500, '🧑 Сергей:\nфото с дачи'],
[-600, '🧑 Сергей:\nфото с дачи'],
]);
expect(bot.telegram.copyMessage.mock.calls.map((c) => [c[0], c[1], c[2]])).toEqual([
[-500, '-1001', 55],
[-600, '-1001', 55],
]);
});
it('skips what the cursor has seen — a replayed history is not copied again', async () => {
const { bot, bc } = await setup(broadcast({ cursor: 5000 }));
bc.copyIncoming({ maxChatId: '100', maxMessageId: 1, time: 4000, text: 'старое', mediaIds: [] });
bc.copyIncoming({ maxChatId: '100', maxMessageId: 2, time: 6000, text: 'новое', mediaIds: [] });
await vi.waitFor(() => expect(bot.telegram.sendMessage).toHaveBeenCalledTimes(1));
expect(bot.telegram.sendMessage.mock.calls[0]![1]).toBe('новое');
});
it('follows edits and deletions of a copied message, and replies to a copied one natively', async () => {
const { store, bot, bc } = await setup(broadcast());
bc.copyIncoming({ maxChatId: '100', maxMessageId: 7, time: 2000, text: '👤 Петя:\nпривет', mediaIds: [] });
bc.copyIncoming({ maxChatId: '100', maxMessageId: 8, time: 3000, text: '🧑 Вы:\nи тебе', mediaIds: [], quote: { maxMessageId: 7, text: 'привет' } });
await vi.waitFor(async () => expect((await store.get('b1'))!.cursor).toBe(3000));
expect(bot.telegram.sendMessage.mock.calls[1]![2]).toEqual({ reply_parameters: { message_id: 1, allow_sending_without_reply: true } });
bc.copyEdit('100', 8, '✏️ (12:05) 🧑 Вы:\nи тебе!');
await vi.waitFor(() => expect(bot.telegram.editMessageText).toHaveBeenCalledWith(-500, 2, undefined, '✏️ (12:05) 🧑 Сергей:\nи тебе!'));
bc.copyDelete('100', 7);
await vi.waitFor(() => expect(bot.telegram.deleteMessages).toHaveBeenCalledWith(-500, [1]));
});
it('a quote of something never copied becomes a quote line', async () => {
const { bot, bc } = await setup(broadcast());
bc.copyIncoming({ maxChatId: '100', maxMessageId: 9, time: 2000, text: '👤 Петя:\nда', mediaIds: [], quote: { maxMessageId: 1, text: 'идёшь?' } });
await vi.waitFor(() => expect(bot.telegram.sendMessage).toHaveBeenCalledWith(-500, '👤 Петя:\n↩️ В ответ на «идёшь?»:\nда', {}));
});
it('stops a broadcast whose place is gone and says so in the topic', async () => {
const { store, bot, bc } = await setup(broadcast());
bot.telegram.sendMessage.mockImplementation(async (chatId: number | string) => {
if (chatId === -500) throw Object.assign(new Error('403'), { response: { error_code: 403, description: 'Forbidden: bot was kicked from the group chat' } });
return { message_id: 99 };
});
bc.copyIncoming({ maxChatId: '100', maxMessageId: 7, time: 2000, text: 'привет', mediaIds: [] });
await vi.waitFor(async () => expect(await store.get('b1')).toBeUndefined());
await vi.waitFor(() => expect(bot.telegram.sendMessage).toHaveBeenCalledWith('-1001', expect.stringMatching(/Трансляция в «Читалка» остановлена/), { message_thread_id: 77 }));
});
it("copies the owner's Telegram messages under their name, formatting kept", async () => {
const { bot, bc } = await setup(broadcast());
const text = { message_id: 10, text: 'жирный', entities: [{ type: 'bold', offset: 0, length: 6 }] } as unknown as Message;
bc.copyOwner('100', 11, 2000, text);
const photo = { message_id: 12, photo: [{ file_id: 'x' }], caption: 'дача' } as unknown as Message;
bc.copyOwner('100', 13, 3000, photo, undefined, '↩️ Переслано от Пети');
const sticker = { message_id: 14, sticker: { file_id: 's' } } as unknown as Message;
bc.copyOwner('100', 15, 4000, sticker);
await vi.waitFor(() => expect(bot.telegram.copyMessage).toHaveBeenCalledTimes(2));
const prefix = '🧑 Сергей:\n';
expect(bot.telegram.sendMessage.mock.calls[0]).toEqual([-500, `${prefix}жирный`, { entities: [{ type: 'bold', offset: prefix.length, length: 6 }] }]);
expect(bot.telegram.copyMessage.mock.calls[0]).toEqual([-500, '-1001', 12, { caption: `${prefix}↩️ Переслано от Пети\nдача`, caption_entities: undefined }]);
expect(bot.telegram.sendMessage.mock.calls[1]![1]).toBe('🧑 Сергей:');
expect(bot.telegram.copyMessage.mock.calls[1]!.slice(0, 3)).toEqual([-500, '-1001', 14]);
});
});