mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-14 05:27:23 +00:00
1.3.5-beta.3: трансляция — /broadcast в теме копирует новые сообщения чата MAX в любые группы и каналы Telegram (сколько угодно мест на чат); место выбирается штатным выбором чата Telegram в личке бота; текст заново под именем владельца вместо «Вы», вложения copyMessage из темы, ваши сообщения из ТГ тоже; правки и удаления повторяются; курсор на трансляцию не даёт переливать историю; бот молчит в группах трансляций и на служебные сообщения о своём добавлении; трансляция сама останавливается, если бота убрали
This commit is contained in:
parent
3589881e87
commit
7f9f7c5ec2
8 changed files with 792 additions and 6 deletions
11
README.md
11
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` — любое сообщение или нажатие кнопки из другого чата (личка боту, добавление в чужую группу и т.п.) игнорируется.
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
438
src/bridge/broadcast.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
100
src/store/broadcastStore.ts
Normal 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
177
tests/broadcast.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue