Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -635,4 +635,27 @@ addChat_enterChatId: |
📝 Enter the <b>username</b> of the chat or channel you want to track

addChat_success: |
🙌 Added new ${channel ? 'channel' : 'chat'} <b>${title}</b>
🙌 Added new ${channel ? 'channel' : 'chat'} <b>${title}</b>

# Broadcast
broadcast_askMessage: |
📢 Send the message you want to broadcast to all users

broadcast_confirm: |
📢 Broadcast will be sent to <b>${userCount}</b> 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: <b>${userCount}</b>.

Confirm broadcast?

broadcast_testFailed: |
❌ Test send failed: <code>${error}</code>
25 changes: 24 additions & 1 deletion locales/ru.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -624,4 +624,27 @@ addChat_enterChatId: |
📝 Введи <b>username</b> чата или канала, который хочешь отслеживать

addChat_success: |
🙌 Добавлен новый ${channel ? 'канал' : 'чат'} <b>${title}</b>
🙌 Добавлен новый ${channel ? 'канал' : 'чат'} <b>${title}</b>

# Broadcast
broadcast_askMessage: |
📢 Отправь сообщение, которое хочешь разослать всем пользователям

broadcast_confirm: |
📢 Рассылка будет отправлена <b>${userCount}</b> пользователям.

Подтвердить отправку?

broadcast_cancelled: |
❌ Рассылка отменена

broadcast_started: |
🚀 Рассылка запущена, это может занять некоторое время...

broadcast_testSent: |
✅ Тестовое сообщение отправлено тебе. Всего пользователей: <b>${userCount}</b>.

Подтвердить рассылку?

broadcast_testFailed: |
❌ Тестовая отправка не удалась: <code>${error}</code>
4 changes: 4 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -63,6 +65,7 @@ const stage = new Stage([
addChatScenes,
shiftSceneUpatePercent,
premiumScenes,
broadcastScenes,
...commonScenes,
...alertScenes,
])
Expand Down Expand Up @@ -95,6 +98,7 @@ export const botInit = (bot: Telegraf<Context>) => {
setupAddChat(bot)
setupTest(bot)
setupAddPremium(bot)
setupBroadcast(bot)

// Listen for crypto transaction hashes (subscription payments via TRX/USDT).
bot.hears(
Expand Down
7 changes: 7 additions & 0 deletions src/commands/broadcast/broadcast.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const BROADCAST_SCENES = {
main: 'broadcastScene',
}

export const BROADCAST_ACTIONS = {
confirm: 'bc_confirm',
}
169 changes: 169 additions & 0 deletions src/commands/broadcast/broadcast.scenes.ts
Original file line number Diff line number Diff line change
@@ -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
)
17 changes: 17 additions & 0 deletions src/commands/broadcast/broadcast.ts
Original file line number Diff line number Diff line change
@@ -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<Context>) {
bot.command(
['broadcast'],
commandWrapper(
{ bossOnly: true, availableForAdmins: false },
async (ctx) => {
// @ts-ignore
await ctx.scene.enter(BROADCAST_SCENES.main)
}
)
)
}
Loading
Loading