diff --git a/locales/en.yaml b/locales/en.yaml index 69d36f4..75a2d13 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -635,4 +635,27 @@ 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... + +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 dde1aff..ae9c006 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -624,4 +624,27 @@ 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: | + 🚀 Рассылка запущена, это может занять некоторое время... + +broadcast_testSent: | + ✅ Тестовое сообщение отправлено тебе. Всего пользователей: ${userCount}. + + Подтвердить рассылку? + +broadcast_testFailed: | + ❌ Тестовая отправка не удалась: ${error} \ 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..6d74641 --- /dev/null +++ b/src/commands/broadcast/broadcast.scenes.ts @@ -0,0 +1,169 @@ +import { Composer } from 'telegraf' + +import { + BROADCAST_ACTIONS, + BROADCAST_SCENES, +} from '@/commands/broadcast/broadcast.constants' +import { + buildConfirmKeyboard, + executeBroadcast, + formatBroadcastReport, +} from '@/commands/broadcast/broadcast.utils' +import { log } from '@/helpers/log' +import { sceneWrapper } from '@/helpers/sceneWrapper' +import { triggerActionRegexp } from '@/helpers/triggerActionRegexp' +import { UserModel } from '@/models' +import { immediateStep } from '@/scenes' +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 + } + + // Count 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, + running: false, + } + + await ctx.replyWithHTML(ctx.i18n.t('broadcast_confirm', { userCount }), { + reply_markup: buildConfirmKeyboard(), + }) + + return ctx.wizard.next() + }) + + return step.on('message', handler) +})() + +// 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 + + // Guard: prevent double-click on "Send to all" + if (state.broadcast?.running) { + await ctx.answerCbQuery('Broadcast is already running') + return + } + + await ctx.answerCbQuery() + + // --- Cancel --- + if (mode === 'no') { + await ctx.editMessageText(ctx.i18n.t('broadcast_cancelled'), { + parse_mode: 'HTML', + }) + return ctx.scene.leave() + } + + // --- 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 + } + + // --- Send to all --- + state.broadcast.running = true + + await ctx.editMessageText(ctx.i18n.t('broadcast_started'), { + parse_mode: 'HTML', + }) + + const users = await UserModel.find( + { botId: ctx.goose.id }, + { id: 1 } + ).lean() + + // Deduplicate — User.id is not unique in DB + const uniqueIds = [...new Set(users.map((u) => u.id))] + + const { fromChatId, messageId } = state.broadcast + + // Report progress ~4 times during the broadcast, minimum every 100 users + const progressInterval = Math.max(100, Math.floor(uniqueIds.length / 4)) + + const result = await executeBroadcast( + uniqueIds, + async (userId) => { + await ctx.telegram.copyMessage(userId, fromChatId, messageId) + }, + { + onProgress(processed, total) { + if (processed % progressInterval === 0) { + const pct = ((processed / total) * 100).toFixed(0) + ctx.telegram + .sendMessage(ctx.from.id, `${pct}% (${processed}/${total})...`) + // eslint-disable-next-line @typescript-eslint/no-empty-function + .catch(() => {}) + } + }, + } + ) + + log.info( + `Broadcast done: ${result.delivered}/${result.total} delivered, ` + + `${result.failed} failed` + ) + + 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, + 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..fedd8b5 --- /dev/null +++ b/src/commands/broadcast/broadcast.utils.test.ts @@ -0,0 +1,240 @@ +import { + BroadcastResult, + buildConfirmKeyboard, + 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, { delayMs: 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, { delayMs: 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, { delayMs: 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' }, + ]) + 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, { delayMs: 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, { delayMs: 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, { delayMs: 30 }) + + const elapsed = Date.now() - start + // 2 pauses of 30ms each (no pause after last send) + expect(elapsed).toBeGreaterThanOrEqual(50) + }) + + it('calls onProgress after each send attempt', async () => { + const sendFn = jest.fn().mockResolvedValue(undefined) + const onProgress = jest.fn() + + await executeBroadcast([10, 20, 30], sendFn, { + delayMs: 0, + onProgress, + }) + + expect(onProgress).toHaveBeenCalledTimes(3) + expect(onProgress).toHaveBeenNthCalledWith(1, 1, 3) + expect(onProgress).toHaveBeenNthCalledWith(2, 2, 3) + expect(onProgress).toHaveBeenNthCalledWith(3, 3, 3) + }) + + it('calls onProgress even when sends fail', async () => { + const sendFn = jest.fn().mockRejectedValue(new Error('fail')) + const onProgress = jest.fn() + + await executeBroadcast([1, 2], sendFn, { delayMs: 0, onProgress }) + + expect(onProgress).toHaveBeenCalledTimes(2) + }) + + it('does not abort broadcast when onProgress throws', async () => { + const sendFn = jest.fn().mockResolvedValue(undefined) + const onProgress = jest.fn().mockRejectedValue(new Error('progress boom')) + + const result = await executeBroadcast([1, 2, 3], sendFn, { + delayMs: 0, + onProgress, + }) + + expect(result.delivered).toBe(3) + expect(result.failed).toBe(0) + }) + + it('uses default 50ms delay when no options provided', async () => { + const sendFn = jest.fn().mockResolvedValue(undefined) + const start = Date.now() + + await executeBroadcast([1, 2], sendFn) + + const elapsed = Date.now() - start + expect(elapsed).toBeGreaterThanOrEqual(40) + }) +}) + +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%') + }) + + 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('