From a1383426e4dde2af735c0576f219f79c136bbbef Mon Sep 17 00:00:00 2001 From: Aleksey Shugaev Date: Mon, 18 May 2026 09:18:23 +0000 Subject: [PATCH 1/3] Add /broadcast command for admin mass messaging with delivery tracking Boss-only wizard: capture any message type, confirm user count, then copy to every user via copyMessage with per-user try/catch, 50ms rate-limit delay, and a final delivery report (count + percentage + failed user list). --- locales/en.yaml | 17 +- locales/ru.yaml | 17 +- src/app.ts | 4 + src/commands/broadcast/broadcast.constants.ts | 7 + src/commands/broadcast/broadcast.scenes.ts | 117 +++++++++++++ src/commands/broadcast/broadcast.ts | 17 ++ .../broadcast/broadcast.utils.test.ts | 159 ++++++++++++++++++ src/commands/broadcast/broadcast.utils.ts | 78 +++++++++ 8 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 src/commands/broadcast/broadcast.constants.ts create mode 100644 src/commands/broadcast/broadcast.scenes.ts create mode 100644 src/commands/broadcast/broadcast.ts create mode 100644 src/commands/broadcast/broadcast.utils.test.ts create mode 100644 src/commands/broadcast/broadcast.utils.ts diff --git a/locales/en.yaml b/locales/en.yaml index 69d36f4..8f92ab2 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -635,4 +635,19 @@ addChat_enterChatId: | 📝 Enter the username of the chat or channel you want to track addChat_success: | - 🙌 Added new ${channel ? 'channel' : 'chat'} ${title} \ No newline at end of file + 🙌 Added new ${channel ? 'channel' : 'chat'} ${title} + +# Broadcast +broadcast_askMessage: | + 📢 Send the message you want to broadcast to all users + +broadcast_confirm: | + 📢 Broadcast will be sent to ${userCount} users. + + Confirm sending? + +broadcast_cancelled: | + ❌ Broadcast cancelled + +broadcast_started: | + 🚀 Broadcast started, this may take a while... \ No newline at end of file diff --git a/locales/ru.yaml b/locales/ru.yaml index dde1aff..0ca1dbd 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -624,4 +624,19 @@ addChat_enterChatId: | 📝 Введи username чата или канала, который хочешь отслеживать addChat_success: | - 🙌 Добавлен новый ${channel ? 'канал' : 'чат'} ${title} \ No newline at end of file + 🙌 Добавлен новый ${channel ? 'канал' : 'чат'} ${title} + +# Broadcast +broadcast_askMessage: | + 📢 Отправь сообщение, которое хочешь разослать всем пользователям + +broadcast_confirm: | + 📢 Рассылка будет отправлена ${userCount} пользователям. + + Подтвердить отправку? + +broadcast_cancelled: | + ❌ Рассылка отменена + +broadcast_started: | + 🚀 Рассылка запущена, это может занять некоторое время... \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 85ff01c..a40df5b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,8 @@ import { addChatScenes } from '@/commands/addChat/addChat.scenes' import { setupAddPremium } from '@/commands/addPremium/addPremium' import { premiumScenes } from '@/commands/addPremium/addPremium.scenes' import { setupAdmin } from '@/commands/admin/admin' +import { setupBroadcast } from '@/commands/broadcast/broadcast' +import { broadcastScenes } from '@/commands/broadcast/broadcast.scenes' import { setupMyToken } from '@/commands/mytoken/mytoken' import { myTokenScenes } from '@/commands/mytoken/mytoken.scenes' import { setupRemove } from '@/commands/remove/remove' @@ -63,6 +65,7 @@ const stage = new Stage([ addChatScenes, shiftSceneUpatePercent, premiumScenes, + broadcastScenes, ...commonScenes, ...alertScenes, ]) @@ -95,6 +98,7 @@ export const botInit = (bot: Telegraf) => { setupAddChat(bot) setupTest(bot) setupAddPremium(bot) + setupBroadcast(bot) // Listen for crypto transaction hashes (subscription payments via TRX/USDT). bot.hears( diff --git a/src/commands/broadcast/broadcast.constants.ts b/src/commands/broadcast/broadcast.constants.ts new file mode 100644 index 0000000..87186da --- /dev/null +++ b/src/commands/broadcast/broadcast.constants.ts @@ -0,0 +1,7 @@ +export const BROADCAST_SCENES = { + main: 'broadcastScene', +} + +export const BROADCAST_ACTIONS = { + confirm: 'bc_confirm', +} diff --git a/src/commands/broadcast/broadcast.scenes.ts b/src/commands/broadcast/broadcast.scenes.ts new file mode 100644 index 0000000..bb81f07 --- /dev/null +++ b/src/commands/broadcast/broadcast.scenes.ts @@ -0,0 +1,117 @@ +import { Composer } from 'telegraf' + +import { + BROADCAST_ACTIONS, + BROADCAST_SCENES, +} from '@/commands/broadcast/broadcast.constants' +import { + executeBroadcast, + formatBroadcastReport, +} from '@/commands/broadcast/broadcast.utils' +import { createActionString } from '@/helpers' +import { log } from '@/helpers/log' +import { sceneWrapper } from '@/helpers/sceneWrapper' +import { UserModel } from '@/models' +import { immediateStep } from '@/scenes' +import { waitButtonClickStep } from '@/scenes/wrappers' +const WizardScene = require('telegraf/scenes/wizard') + +// Step 1: Ask for the message to broadcast +const askForMessageStep = immediateStep('broadcastAskMessage', async (ctx) => { + await ctx.replyWithHTML(ctx.i18n.t('broadcast_askMessage')) + return ctx.wizard.next() +}) + +// Step 2: Capture any message and ask for confirmation +const captureMessageStep = (() => { + const step = new Composer() + + const handler = sceneWrapper('broadcastCaptureMessage', async (ctx) => { + const { state } = ctx.wizard + const msg = ctx.message + + if (!msg) { + await ctx.replyWithHTML(ctx.i18n.t('broadcast_askMessage')) + return + } + + state.broadcast = { + fromChatId: msg.chat.id, + messageId: msg.message_id, + } + + // Count users for the current bot + const userCount = await UserModel.countDocuments({ botId: ctx.goose.id }) + + await ctx.replyWithHTML(ctx.i18n.t('broadcast_confirm', { userCount }), { + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Send', + callback_data: createActionString(BROADCAST_ACTIONS.confirm, { + ok: 1, + }), + }, + { + text: 'Cancel', + callback_data: createActionString(BROADCAST_ACTIONS.confirm, { + ok: 0, + }), + }, + ], + ], + }, + }) + + return ctx.wizard.next() + }) + + // Listen for any message type (text, photo, video, sticker, etc.) + return step.on('message', handler) +})() + +// Step 3: Handle confirmation button click +const confirmStep = waitButtonClickStep( + BROADCAST_ACTIONS.confirm, + 'broadcastConfirm', + async (ctx, actionPayload, state) => { + if (!actionPayload.ok) { + await ctx.replyWithHTML(ctx.i18n.t('broadcast_cancelled')) + return ctx.scene.leave() + } + + const { fromChatId, messageId } = state.broadcast + + await ctx.replyWithHTML(ctx.i18n.t('broadcast_started')) + + const users = await UserModel.find( + { botId: ctx.goose.id }, + { id: 1 } + ).lean() + + const userIds: number[] = users.map((u) => u.id) + + const result = await executeBroadcast(userIds, async (userId) => { + await ctx.telegram.copyMessage(userId, fromChatId, messageId) + }) + + log.info( + `Broadcast done: ${result.delivered}/${result.total} delivered, ` + + `${result.failed} failed` + ) + + await ctx.replyWithHTML(formatBroadcastReport(result), { + disable_web_page_preview: true, + }) + + return ctx.scene.leave() + } +) + +export const broadcastScenes = new WizardScene( + BROADCAST_SCENES.main, + askForMessageStep, + captureMessageStep, + confirmStep +) diff --git a/src/commands/broadcast/broadcast.ts b/src/commands/broadcast/broadcast.ts new file mode 100644 index 0000000..c751919 --- /dev/null +++ b/src/commands/broadcast/broadcast.ts @@ -0,0 +1,17 @@ +import { Context, Telegraf } from 'telegraf' + +import { BROADCAST_SCENES } from '@/commands/broadcast/broadcast.constants' +import { commandWrapper } from '@/helpers/commandWrapper' + +export function setupBroadcast(bot: Telegraf) { + bot.command( + ['broadcast'], + commandWrapper( + { bossOnly: true, availableForAdmins: false }, + async (ctx) => { + // @ts-ignore + await ctx.scene.enter(BROADCAST_SCENES.main) + } + ) + ) +} diff --git a/src/commands/broadcast/broadcast.utils.test.ts b/src/commands/broadcast/broadcast.utils.test.ts new file mode 100644 index 0000000..ec80fd0 --- /dev/null +++ b/src/commands/broadcast/broadcast.utils.test.ts @@ -0,0 +1,159 @@ +import { + BroadcastResult, + executeBroadcast, + formatBroadcastReport, +} from '@/commands/broadcast/broadcast.utils' + +describe('executeBroadcast', () => { + it('delivers to all users when sendFn succeeds', async () => { + const sendFn = jest.fn().mockResolvedValue(undefined) + const userIds = [100, 200, 300] + + const result = await executeBroadcast(userIds, sendFn, 0) + + expect(result).toEqual({ + delivered: 3, + failed: 0, + total: 3, + errors: [], + }) + expect(sendFn).toHaveBeenCalledTimes(3) + expect(sendFn).toHaveBeenCalledWith(100) + expect(sendFn).toHaveBeenCalledWith(200) + expect(sendFn).toHaveBeenCalledWith(300) + }) + + it('handles empty user list', async () => { + const sendFn = jest.fn() + + const result = await executeBroadcast([], sendFn, 0) + + expect(result).toEqual({ + delivered: 0, + failed: 0, + total: 0, + errors: [], + }) + expect(sendFn).not.toHaveBeenCalled() + }) + + it('continues after individual send failures', async () => { + const sendFn = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce( + new Error('Forbidden: bot was blocked by the user') + ) + .mockResolvedValueOnce(undefined) + + const result = await executeBroadcast([1, 2, 3], sendFn, 0) + + expect(result.delivered).toBe(2) + expect(result.failed).toBe(1) + expect(result.total).toBe(3) + expect(result.errors).toEqual([ + { userId: 2, error: 'Forbidden: bot was blocked by the user' }, + ]) + // All three users were attempted + expect(sendFn).toHaveBeenCalledTimes(3) + }) + + it('handles all failures', async () => { + const sendFn = jest.fn().mockRejectedValue(new Error('network error')) + + const result = await executeBroadcast([10, 20], sendFn, 0) + + expect(result.delivered).toBe(0) + expect(result.failed).toBe(2) + expect(result.errors).toHaveLength(2) + }) + + it('handles non-Error throws', async () => { + const sendFn = jest.fn().mockRejectedValue('string error') + + const result = await executeBroadcast([1], sendFn, 0) + + expect(result.errors[0].error).toBe('string error') + }) + + it('respects delay between sends', async () => { + const sendFn = jest.fn().mockResolvedValue(undefined) + const start = Date.now() + + await executeBroadcast([1, 2, 3], sendFn, 30) + + const elapsed = Date.now() - start + // 2 pauses of 30ms each (no pause after last send) + expect(elapsed).toBeGreaterThanOrEqual(50) + }) +}) + +describe('formatBroadcastReport', () => { + it('formats a successful broadcast', () => { + const result: BroadcastResult = { + delivered: 100, + failed: 0, + total: 100, + errors: [], + } + + const report = formatBroadcastReport(result) + + expect(report).toContain('Total: 100') + expect(report).toContain('Delivered: 100') + expect(report).toContain('Failed: 0') + expect(report).toContain('100.0%') + expect(report).not.toContain('Failed users') + }) + + it('formats a partial delivery with errors', () => { + const result: BroadcastResult = { + delivered: 8, + failed: 2, + total: 10, + errors: [ + { userId: 5, error: 'bot blocked' }, + { userId: 9, error: 'user deactivated' }, + ], + } + + const report = formatBroadcastReport(result) + + expect(report).toContain('Delivered: 8') + expect(report).toContain('Failed: 2') + expect(report).toContain('80.0%') + expect(report).toContain('Failed users') + expect(report).toContain('5') + expect(report).toContain('bot blocked') + }) + + it('truncates long error lists', () => { + const errors = Array.from({ length: 25 }, (_, i) => ({ + userId: i + 1, + error: 'blocked', + })) + const result: BroadcastResult = { + delivered: 75, + failed: 25, + total: 100, + errors, + } + + const report = formatBroadcastReport(result) + + expect(report).toContain('and 5 more') + }) + + it('handles zero total users', () => { + const result: BroadcastResult = { + delivered: 0, + failed: 0, + total: 0, + errors: [], + } + + const report = formatBroadcastReport(result) + + expect(report).toContain('0.0%') + }) +}) diff --git a/src/commands/broadcast/broadcast.utils.ts b/src/commands/broadcast/broadcast.utils.ts new file mode 100644 index 0000000..740d9b9 --- /dev/null +++ b/src/commands/broadcast/broadcast.utils.ts @@ -0,0 +1,78 @@ +export interface BroadcastResult { + delivered: number + failed: number + total: number + errors: Array<{ userId: number; error: string }> +} + +/** + * Sends a message to a list of users via the provided send function. + * Each call is wrapped in try/catch so a single failure never aborts the run. + * + * @param userIds – unique Telegram user IDs to deliver to + * @param sendFn – async callback that sends one message (injected for testability) + * @param delayMs – pause between sends to respect Telegram rate limits (default 50 ms) + */ +export async function executeBroadcast( + userIds: number[], + sendFn: (userId: number) => Promise, + delayMs = 50 +): Promise { + const result: BroadcastResult = { + delivered: 0, + failed: 0, + total: userIds.length, + errors: [], + } + + for (let i = 0; i < userIds.length; i++) { + const userId = userIds[i] + + try { + await sendFn(userId) + result.delivered++ + } catch (err) { + result.failed++ + result.errors.push({ + userId, + error: err?.message ?? String(err), + }) + } + + // Rate-limit pause between sends (skip after the last one) + if (delayMs > 0 && i < userIds.length - 1) { + await new Promise((r) => setTimeout(r, delayMs)) + } + } + + return result +} + +export function formatBroadcastReport(result: BroadcastResult): string { + const pct = + result.total > 0 + ? ((result.delivered / result.total) * 100).toFixed(1) + : '0.0' + + const lines = [ + 'Broadcast report', + '', + `Total: ${result.total}`, + `Delivered: ${result.delivered}`, + `Failed: ${result.failed}`, + `Delivery rate: ${pct}%`, + ] + + if (result.errors.length > 0) { + const maxShown = 20 // avoid huge messages + lines.push('', 'Failed users:') + for (const e of result.errors.slice(0, maxShown)) { + lines.push(`${e.userId} — ${e.error}`) + } + if (result.errors.length > maxShown) { + lines.push(`... and ${result.errors.length - maxShown} more`) + } + } + + return lines.join('\n') +} From 5aa981d3e12d69f87992ea1aabe6d38c0b8ea6b0 Mon Sep 17 00:00:00 2001 From: Aleksey Shugaev Date: Mon, 18 May 2026 09:26:48 +0000 Subject: [PATCH 2/3] Harden broadcast: add test-send button, dedup users, escape HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Test (only me)" button — sends to boss only, stays on same step so you can test repeatedly before confirming - Deduplicate user IDs before broadcast (User.id is not unique in DB) - Escape HTML in error messages to avoid breaking Telegram's parser - Wrap final report send in try/catch so a Telegram hiccup doesn't lose the scene - Use editMessageText instead of new messages — cleaner UX, removes stale inline keyboards - answerCbQuery on every button press to dismiss Telegram spinner - Store userCount in wizard state to avoid re-querying on test sends --- locales/en.yaml | 10 +- locales/ru.yaml | 10 +- src/commands/broadcast/broadcast.scenes.ts | 106 +++++++++++------- .../broadcast/broadcast.utils.test.ts | 36 +++++- src/commands/broadcast/broadcast.utils.ts | 41 ++++++- 5 files changed, 159 insertions(+), 44 deletions(-) diff --git a/locales/en.yaml b/locales/en.yaml index 8f92ab2..75a2d13 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -650,4 +650,12 @@ broadcast_cancelled: | ❌ Broadcast cancelled broadcast_started: | - 🚀 Broadcast started, this may take a while... \ No newline at end of file + 🚀 Broadcast started, this may take a while... + +broadcast_testSent: | + ✅ Test message sent to you. Total users: ${userCount}. + + Confirm broadcast? + +broadcast_testFailed: | + ❌ Test send failed: ${error} \ No newline at end of file diff --git a/locales/ru.yaml b/locales/ru.yaml index 0ca1dbd..ae9c006 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -639,4 +639,12 @@ broadcast_cancelled: | ❌ Рассылка отменена broadcast_started: | - 🚀 Рассылка запущена, это может занять некоторое время... \ No newline at end of file + 🚀 Рассылка запущена, это может занять некоторое время... + +broadcast_testSent: | + ✅ Тестовое сообщение отправлено тебе. Всего пользователей: ${userCount}. + + Подтвердить рассылку? + +broadcast_testFailed: | + ❌ Тестовая отправка не удалась: ${error} \ No newline at end of file diff --git a/src/commands/broadcast/broadcast.scenes.ts b/src/commands/broadcast/broadcast.scenes.ts index bb81f07..9656f49 100644 --- a/src/commands/broadcast/broadcast.scenes.ts +++ b/src/commands/broadcast/broadcast.scenes.ts @@ -5,15 +5,15 @@ import { BROADCAST_SCENES, } from '@/commands/broadcast/broadcast.constants' import { + buildConfirmKeyboard, executeBroadcast, formatBroadcastReport, } from '@/commands/broadcast/broadcast.utils' -import { createActionString } from '@/helpers' import { log } from '@/helpers/log' import { sceneWrapper } from '@/helpers/sceneWrapper' +import { triggerActionRegexp } from '@/helpers/triggerActionRegexp' import { UserModel } from '@/models' import { immediateStep } from '@/scenes' -import { waitButtonClickStep } from '@/scenes/wrappers' const WizardScene = require('telegraf/scenes/wizard') // Step 1: Ask for the message to broadcast @@ -35,64 +35,84 @@ const captureMessageStep = (() => { return } + // Count unique users for the current bot + const userCount = await UserModel.countDocuments({ botId: ctx.goose.id }) + state.broadcast = { fromChatId: msg.chat.id, messageId: msg.message_id, + userCount, } - // Count users for the current bot - const userCount = await UserModel.countDocuments({ botId: ctx.goose.id }) - await ctx.replyWithHTML(ctx.i18n.t('broadcast_confirm', { userCount }), { - reply_markup: { - inline_keyboard: [ - [ - { - text: 'Send', - callback_data: createActionString(BROADCAST_ACTIONS.confirm, { - ok: 1, - }), - }, - { - text: 'Cancel', - callback_data: createActionString(BROADCAST_ACTIONS.confirm, { - ok: 0, - }), - }, - ], - ], - }, + reply_markup: buildConfirmKeyboard(), }) return ctx.wizard.next() }) - // Listen for any message type (text, photo, video, sticker, etc.) return step.on('message', handler) })() -// Step 3: Handle confirmation button click -const confirmStep = waitButtonClickStep( - BROADCAST_ACTIONS.confirm, - 'broadcastConfirm', - async (ctx, actionPayload, state) => { - if (!actionPayload.ok) { - await ctx.replyWithHTML(ctx.i18n.t('broadcast_cancelled')) +// Step 3: Handle confirm / test / cancel button clicks +const confirmStep = (() => { + const step = new Composer() + + const handler = sceneWrapper('broadcastConfirm', async (ctx) => { + const { state } = ctx.wizard + const actionPayload = JSON.parse(ctx.match[1]) + const mode: string = actionPayload.m + + await ctx.answerCbQuery() + + // --- Cancel --- + if (mode === 'no') { + await ctx.editMessageText(ctx.i18n.t('broadcast_cancelled'), { + parse_mode: 'HTML', + }) return ctx.scene.leave() } - const { fromChatId, messageId } = state.broadcast + // --- Test (send only to boss) --- + if (mode === 'test') { + const { fromChatId, messageId, userCount } = state.broadcast + + try { + await ctx.telegram.copyMessage(ctx.from.id, fromChatId, messageId) + await ctx.editMessageText( + ctx.i18n.t('broadcast_testSent', { userCount }), + { parse_mode: 'HTML', reply_markup: buildConfirmKeyboard() } + ) + } catch (err) { + log.error('Broadcast test-send failed:', err) + await ctx.editMessageText( + ctx.i18n.t('broadcast_testFailed', { + error: err?.message ?? String(err), + }), + { parse_mode: 'HTML', reply_markup: buildConfirmKeyboard() } + ) + } + + // Stay on same step — boss can test again, confirm, or cancel + return + } - await ctx.replyWithHTML(ctx.i18n.t('broadcast_started')) + // --- Send to all --- + await ctx.editMessageText(ctx.i18n.t('broadcast_started'), { + parse_mode: 'HTML', + }) const users = await UserModel.find( { botId: ctx.goose.id }, { id: 1 } ).lean() - const userIds: number[] = users.map((u) => u.id) + // Deduplicate — User.id is not unique in DB + const uniqueIds = [...new Set(users.map((u) => u.id))] - const result = await executeBroadcast(userIds, async (userId) => { + const { fromChatId, messageId } = state.broadcast + + const result = await executeBroadcast(uniqueIds, async (userId) => { await ctx.telegram.copyMessage(userId, fromChatId, messageId) }) @@ -101,13 +121,19 @@ const confirmStep = waitButtonClickStep( `${result.failed} failed` ) - await ctx.replyWithHTML(formatBroadcastReport(result), { - disable_web_page_preview: true, - }) + try { + await ctx.replyWithHTML(formatBroadcastReport(result), { + disable_web_page_preview: true, + }) + } catch (reportErr) { + log.error('Failed to send broadcast report:', reportErr) + } return ctx.scene.leave() - } -) + }) + + return step.action(triggerActionRegexp(BROADCAST_ACTIONS.confirm), handler) +})() export const broadcastScenes = new WizardScene( BROADCAST_SCENES.main, diff --git a/src/commands/broadcast/broadcast.utils.test.ts b/src/commands/broadcast/broadcast.utils.test.ts index ec80fd0..df87a00 100644 --- a/src/commands/broadcast/broadcast.utils.test.ts +++ b/src/commands/broadcast/broadcast.utils.test.ts @@ -1,5 +1,6 @@ import { BroadcastResult, + buildConfirmKeyboard, executeBroadcast, formatBroadcastReport, } from '@/commands/broadcast/broadcast.utils' @@ -54,7 +55,6 @@ describe('executeBroadcast', () => { expect(result.errors).toEqual([ { userId: 2, error: 'Forbidden: bot was blocked by the user' }, ]) - // All three users were attempted expect(sendFn).toHaveBeenCalledTimes(3) }) @@ -156,4 +156,38 @@ describe('formatBroadcastReport', () => { expect(report).toContain('0.0%') }) + + it('escapes HTML in error messages', () => { + const result: BroadcastResult = { + delivered: 0, + failed: 1, + total: 1, + errors: [{ userId: 1, error: '' }], + } + + const report = formatBroadcastReport(result) + + expect(report).not.toContain('