diff --git a/README.md b/README.md index 680c6e2..ea141d6 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ FILES_LISTEN= # локальный порт службы для ваш Новые сообщения чата MAX можно копировать в обычные группы и каналы Telegram — для тех, у кого MAX нет. Например, чат садового товарищества — в канал, который читают соседи. -- **Включить.** Напишите `/broadcast` в теме чата и нажмите «👥 В группу» или «📢 В канал»: Telegram покажет ваши группы или каналы и сам добавит туда бота администратором (публиковать, закреплять). Если бот там уже есть — «🔎 Бот уже там»: выбор откроется в личке с ботом. +- **Включить.** Напишите `/broadcast` в теме чата и нажмите «👥 В группу» или «📢 В канал»: Telegram покажет ваши группы или каналы и сам добавит туда бота администратором (публиковать, закреплять). Если бот там уже есть — «🔎 Бот уже там»: выбор откроется в личке с ботом. Карточка `/broadcast` в теме сразу покажет новое место — там же «📌» и «⏹». - **Мест — сколько угодно.** Один чат можно транслировать в несколько групп и каналов, а в одну группу — несколько чатов. `/broadcast` в теме показывает, куда идёт трансляция, и останавливает любую кнопкой «⏹». - **Что копируется.** Новые сообщения с момента включения: текст, фото, файлы, голосовые, с подписью автора, как в теме. Вместо «🧑 Вы» стоит ваше имя из MAX, ваши сообщения, написанные в теме из Telegram, тоже идут туда. Правки и удаления в MAX повторяются, пока мост не перезапускался. Опросы — текстом: голоса из Telegram в MAX не попадут. История до включения не переносится, сообщения, пришедшие пока мост был выключен, — догоняются. - **Закреп «📌».** Кнопка «📌 Закреп» в `/broadcast` включает закрепление каждого нового сообщения в этом месте — тихо, без уведомлений. В группе, где все пишут, сообщения из MAX легко найти по закрепам. Боту нужно право закреплять сообщения (в канале — редактировать); с правом удалять он ещё и убирает строку «… закрепил». diff --git a/package-lock.json b/package-lock.json index 20d7051..fe97ed3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c005166..baec592 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bridge/broadcast.ts b/src/bridge/broadcast.ts index c54ce1d..0004f50 100644 --- a/src/bridge/broadcast.ts +++ b/src/bridge/broadcast.ts @@ -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(); + async function refreshCard(maxChatId: string, requesterId: number): Promise { + 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_` may come together. */ + const binding = new Set(); + /** + * 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 { 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 { @@ -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_` 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) => { diff --git a/tests/broadcast.test.ts b/tests/broadcast.test.ts index e80519a..1c780d1 100644 --- a/tests/broadcast.test.ts +++ b/tests/broadcast.test.ts @@ -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> => ({ 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; - 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> } }])[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}`);