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('