1.3.8-beta.13: трансляция в группу — за один /broadcast: включается в момент, когда Telegram добавил бота в группу (my_chat_member, как у каналов; добавленный админом бот /start bc_ не получает — проверено вживую 2026-10-15), а пришедший следом /start ничего не дублирует; карточка /broadcast в теме обновляется сама — новое место с «📌» и «⏹» появляется в ней сразу

This commit is contained in:
Folist 2026-10-15 20:31:14 +03:00
parent 35c6ea9466
commit cbe4c3a54a
5 changed files with 77 additions and 24 deletions

View file

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

4
package-lock.json generated
View file

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

View file

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

View file

@ -170,6 +170,7 @@ export function isPinRefusal(err: unknown): boolean {
return r?.error_code === 403 || /not enough rights|need administrator rights|CHAT_ADMIN_REQUIRED|have no rights/i.test(r?.description ?? '');
}
const CANCEL = '✖️ Отмена';
const ALREADY = 'Этот чат уже транслируется туда.';
type CopyLink = { broadcastId: string; targetChatId: number; ids: number[]; anchor?: number };
type PickRequest = { maxChatId: string; requesterId: number; at: number };
@ -394,6 +395,16 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
async function card(maxChatId: string, requesterId: number) {
return broadcastCard(await deps.chatTitle(maxChatId), await store.forChat(maxChatId), issue(maxChatId, requesterId));
}
/** The newest /broadcast card per MAX chat (its message in the bridge group) — a place switched on from Telegram's own picker shows up in it at once, «📌» included. */
const cards = new Map<string, number>();
async function refreshCard(maxChatId: string, requesterId: number): Promise<void> {
const messageId = cards.get(maxChatId);
if (messageId == null) return;
const { text, markup } = await card(maxChatId, requesterId);
await bot.telegram.editMessageText(targetGroupId, messageId, undefined, text, { reply_markup: markup }).catch((err) => {
if (!/not modified/i.test((err as Error).message)) logger.info(`Broadcast: the /broadcast card was not refreshed (${(err as Error).message})`);
});
}
const removeKeyboard = { reply_markup: { remove_keyboard: true as const } };
const botHandle = () => (bot.botInfo?.username ? `@${bot.botInfo.username}` : 'бота');
@ -405,21 +416,34 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
return member?.status === 'member' || member?.status === 'administrator' || (member?.status === 'restricted' && member.can_send_messages);
}
/** Switches the broadcast on (rights checked by the caller): saved, a hello in the place, a notice in the topic. Returns what to tell the admin. */
/** In-flight binds, `${maxChatId}:${chatId}`: a group's join and its `/start bc_<code>` may come together. */
const binding = new Set<string>();
/**
* Switches the broadcast on (rights checked by the caller): saved, a hello in the place, a notice
* in the topic, the topic's card refreshed. Returns what to tell the admin — ALREADY when that
* place is on already (or being switched on right now).
*/
async function bind(p: PickRequest, chatId: number, kind: 'group' | 'channel', knownTitle?: string): Promise<string> {
if (String(chatId) === targetGroupId) return 'Это группа самого моста — выберите другую.';
if ((await store.forChat(p.maxChatId)).some((b) => b.targetChatId === chatId)) return 'Этот чат уже транслируется туда.';
const chat = knownTitle ? undefined : await bot.telegram.getChat(chatId).catch(() => undefined);
const targetTitle = knownTitle ?? (chat && 'title' in chat ? chat.title : undefined) ?? String(chatId);
const title = await deps.chatTitle(p.maxChatId);
const now = Date.now();
await store.add({ id: randomBytes(6).toString('base64url'), maxChatId: p.maxChatId, targetChatId: chatId, targetTitle, targetType: kind, since: now, cursor: now });
logger.info(`Broadcast on: MAX chat ${p.maxChatId} -> ${kind} ${chatId}`);
await bot.telegram
.sendMessage(chatId, `📡 Сюда транслируется чат «${title}» из MAX — новые сообщения будут появляться здесь.`)
.catch((err) => logger.error('Broadcast hello to the target failed', err));
await notifyTopic(p.maxChatId, `📡 Трансляция в «${targetTitle}» включена — новые сообщения этого чата копируются туда. Управление — /broadcast.`);
return `✅ Готово: чат «${title}» транслируется в «${targetTitle}». Остановить — /broadcast в теме чата.`;
const key = `${p.maxChatId}:${chatId}`;
if (binding.has(key) || (await store.forChat(p.maxChatId)).some((b) => b.targetChatId === chatId)) return ALREADY;
binding.add(key);
try {
const chat = knownTitle ? undefined : await bot.telegram.getChat(chatId).catch(() => undefined);
const targetTitle = knownTitle ?? (chat && 'title' in chat ? chat.title : undefined) ?? String(chatId);
const title = await deps.chatTitle(p.maxChatId);
const now = Date.now();
await store.add({ id: randomBytes(6).toString('base64url'), maxChatId: p.maxChatId, targetChatId: chatId, targetTitle, targetType: kind, since: now, cursor: now });
logger.info(`Broadcast on: MAX chat ${p.maxChatId} -> ${kind} ${chatId}`);
await bot.telegram
.sendMessage(chatId, `📡 Сюда транслируется чат «${title}» из MAX — новые сообщения будут появляться здесь.`)
.catch((err) => logger.error('Broadcast hello to the target failed', err));
await notifyTopic(p.maxChatId, `📡 Трансляция в «${targetTitle}» включена — новые сообщения этого чата копируются туда. Закреп «📌» и остановка — в карточке /broadcast.`);
await refreshCard(p.maxChatId, p.requesterId);
return `✅ Готово: чат «${title}» транслируется в «${targetTitle}». Закреп «📌» и остановка — в карточке /broadcast в теме чата.`;
} finally {
binding.delete(key);
}
}
async function handlePrivate(ctx: Context): Promise<boolean> {
@ -492,22 +516,29 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
const chat = ctx.chat;
if (!chat || chat.type === 'private') return false;
// «📢 В канал»: Telegram made the bot an admin there — the promoting admin's newest card says which MAX chat.
// «📢 В канал»: Telegram made the bot an admin there; «👥 В группу»: Telegram added it to a group
// (added as an admin, it may get no `/start bc_<code>` at all — live 2026-10-15). Either way the
// adding admin's newest card says which MAX chat.
const change = ctx.myChatMember;
if (change) {
const now = change.new_chat_member;
const before = change.old_chat_member.status;
const promoted = chat.type === 'channel' && now.status === 'administrator' && before !== 'administrator';
const p = promoted && userId != null ? codeOf(userId) : undefined;
const joined = chat.type !== 'channel' && (before === 'left' || before === 'kicked') && now.status !== 'left' && now.status !== 'kicked';
const p = (promoted || joined) && userId != null ? codeOf(userId) : undefined;
if (!p || userId == null) return true; // nothing of ours — and never a reply into someone's channel
if (!(now.status === 'administrator' && now.can_post_messages)) {
if (promoted && !(now.status === 'administrator' && now.can_post_messages)) {
await bot.telegram
.sendMessage(userId, `Бота добавили в «${chat.title}» без права публиковать сообщения — трансляции туда не будет. Дайте ему это право и нажмите «📢 В канал» ещё раз.`)
.catch(() => {});
return true;
}
const answer = await bind(p, chat.id, 'channel', chat.title);
await bot.telegram.sendMessage(userId, answer).catch((err) => logger.error('Broadcast: could not tell the admin in DM', err));
if (joined && now.status === 'restricted' && !now.can_send_messages) {
await bot.telegram.sendMessage(userId, `Бота добавили в «${chat.title}», но запретили ему писать — разрешите отправку сообщений и нажмите «👥 В группу» ещё раз.`).catch(() => {});
return true;
}
const answer = await bind(p, chat.id, promoted ? 'channel' : 'group', chat.title);
if (answer !== ALREADY) await bot.telegram.sendMessage(userId, answer).catch((err) => logger.error('Broadcast: could not tell the admin in DM', err));
return true;
}
@ -525,6 +556,8 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
return true;
}
const answer = await bind(p, chat.id, 'group', 'title' in chat ? chat.title : undefined);
// Switched on by the join already (my_chat_member) — nothing to add.
if (answer === ALREADY) return true;
// The hello already went into the group; the admin gets the outcome in DM (a refusal stays visible there).
if (!answer.startsWith('✅')) await ctx.reply(answer).catch(() => {});
await bot.telegram.sendMessage(userId!, answer).catch(() => {});
@ -554,7 +587,8 @@ export function createBroadcasts(deps: BroadcastDeps): Broadcasts {
return;
}
const { text, markup } = await card(maxChatId, userId);
await ctx.reply(text, { message_thread_id: topicId, reply_markup: markup });
const sent = await ctx.reply(text, { message_thread_id: topicId, reply_markup: markup });
if (sent?.message_id != null) cards.set(maxChatId, sent.message_id);
});
bot.action(/^tlmx_bc:stop:([\w-]+)$/, async (ctx) => {

View file

@ -264,6 +264,7 @@ describe('switching a broadcast on', () => {
sendMessage: vi.fn(async (_chatId: number | string, _text: string, _extra?: unknown) => ({ message_id: next++ })),
getChatMember: vi.fn(async (_chatId: number | string, _userId: number): Promise<Record<string, unknown>> => ({ status: 'member' })),
getChat: vi.fn(async (chatId: number | string) => ({ id: chatId, title: 'Из getChat' })),
editMessageText: vi.fn(async (_chatId: number | string, _id: number, _inline: unknown, _text: string, _extra?: unknown) => true),
};
const bot = { telegram, botInfo: { id: 42, username: 'tlmx_bot' }, command: vi.fn(), action: vi.fn() };
const bc = createBroadcasts({
@ -279,7 +280,7 @@ describe('switching a broadcast on', () => {
bc.register();
// An admin (id 9) writes /broadcast in the chat's topic: the card's links carry a fresh code.
const onBroadcast = bot.command.mock.calls.find((c) => c[0] === 'broadcast')![1] as (ctx: unknown) => Promise<void>;
const reply = vi.fn(async () => ({}));
const reply = vi.fn(async () => ({ message_id: 500 }));
await onBroadcast({ message: { message_thread_id: 5 }, from: { id: 9 }, reply });
const markup = (reply.mock.calls[0] as unknown as [string, { reply_markup: { inline_keyboard: Array<Array<{ url?: string }>> } }])[1].reply_markup;
const groupLink = markup.inline_keyboard.flat().find((b) => b.url?.includes('startgroup'))!.url!;
@ -298,6 +299,24 @@ describe('switching a broadcast on', () => {
expect(telegram.sendMessage.mock.calls[2]![1]).toMatch(/^✅ Готово: чат «Дача» транслируется в «Читалка»/);
});
it('«👥 В группу» with admin rights: the join itself switches it on, the card in the topic shows it, a later /start adds nothing', async () => {
const { store, telegram, bc, code } = await setup();
const join = { chat: { id: -700, type: 'supergroup', title: 'Читалка' }, from: { id: 9 }, myChatMember: { old_chat_member: { status: 'left' }, new_chat_member: { status: 'administrator', can_pin_messages: true } } };
expect(await bc.handleChat(join as never)).toBe(true);
expect((await store.forChat('100')).map((b) => [b.targetChatId, b.targetType, b.targetTitle])).toEqual([[-700, 'group', 'Читалка']]);
expect(telegram.editMessageText).toHaveBeenCalledWith('-1001', 500, undefined, expect.stringMatching(/👥 Читалка/), expect.anything());
expect(telegram.sendMessage).toHaveBeenLastCalledWith(9, expect.stringMatching(/^✅ Готово/));
const sends = telegram.sendMessage.mock.calls.length;
const start = groupCtx(9, `/start@tlmx_bot bc_${code}`);
expect(await bc.handleChat(start as never)).toBe(true);
expect(start.reply).not.toHaveBeenCalled();
expect(telegram.sendMessage.mock.calls.length).toBe(sends);
expect(await store.all()).toHaveLength(1);
// Someone without a card adding the bot somewhere switches nothing on.
expect(await bc.handleChat({ ...join, chat: { id: -701, type: 'group', title: 'Чужая' }, from: { id: 10 } } as never)).toBe(true);
expect(await store.all()).toHaveLength(1);
});
it("someone else's /start with the code switches nothing on", async () => {
const { store, bc, code } = await setup();
const ctx = groupCtx(10, `/start@tlmx_bot bc_${code}`);