diff --git a/README.md b/README.md index 28aeff3..83ac7d6 100644 --- a/README.md +++ b/README.md @@ -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` — любое сообщение или нажатие кнопки из другого чата (личка боту, добавление в чужую группу и т.п.) игнорируется. diff --git a/package-lock.json b/package-lock.json index 4ff2322..64cf549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ce66ba5..af72042 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bridge/broadcast.ts b/src/bridge/broadcast.ts new file mode 100644 index 0000000..308cc37 --- /dev/null +++ b/src/bridge/broadcast.ts @@ -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; + /** The MAX chat behind a topic — /broadcast is written in it. */ + chatOfTopic: (topicId: number) => Promise; + /** The MAX chat's topic — where «включена / остановлена» notices go. */ + topicOf: (maxChatId: string) => Promise; + /** The MAX account owner's name — «🧑 Вы:» means nothing to readers. */ + ownerName: () => Promise; + isGroupAdmin: (userId: number) => Promise; +} + +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; + /** The DM part of switching one on (deep link, chat picker); true when the update was ours. */ + handlePrivate(ctx: Context): Promise; + /** /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(); + /** One chain per broadcast — copies keep the chat's order. */ + const queues = new Map>(); + /** Deep-link codes from /broadcast cards and the picker requests they open in the DM. */ + const codes = new Map(); + const requests = new Map(); + + 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 { + const run = (queues.get(id) ?? Promise.resolve()).then(job); + queues.set(id, run.catch(() => undefined)); + } + + async function notifyTopic(maxChatId: string, text: string): Promise { + 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 { + 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): Promise { + 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 { + 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, + }; +} diff --git a/src/bridge/sync.ts b/src/bridge/sync.ts index 6938edc..93579d7 100644 --- a/src/bridge/sync.ts +++ b/src/bridge/sync.ts @@ -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. diff --git a/src/server/app.ts b/src/server/app.ts index f69664c..2fca852 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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 <название>' }, diff --git a/src/store/broadcastStore.ts b/src/store/broadcastStore.ts new file mode 100644 index 0000000..470e064 --- /dev/null +++ b/src/store/broadcastStore.ts @@ -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: 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 = Promise.resolve(); + private pendingLoad: Promise | null = null; + + constructor(private readonly filePath: string = path.join(process.cwd(), '.data', 'broadcasts.json')) {} + + async all(): Promise { + return [...(await this.load())]; + } + + async forChat(maxChatId: string): Promise { + return (await this.load()).filter((b) => b.maxChatId === maxChatId); + } + + async get(id: string): Promise { + 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 { + return (await this.load()).some((b) => b.targetChatId === chatId); + } + + async add(b: Broadcast): Promise { + const all = await this.load(); + all.push(b); + await this.persist(all); + } + + async remove(id: string): Promise { + 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 { + 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 { + 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 { + const run = this.writeQueue.then(() => this.writeToDisk(list)); + this.writeQueue = run.catch(() => undefined); + return run; + } + + private async writeToDisk(list: Broadcast[]): Promise { + 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); + } +} diff --git a/tests/broadcast.test.ts b/tests/broadcast.test.ts new file mode 100644 index 0000000..eefd8fa --- /dev/null +++ b/tests/broadcast.test.ts @@ -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 => ({ + 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]); + }); +});