tgbot_broadcast.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. package tgbot
  2. import (
  3. "context"
  4. "errors"
  5. "slices"
  6. "strconv"
  7. "sync"
  8. "sync/atomic"
  9. "time"
  10. telegoapi "github.com/mymmrac/telego/telegoapi"
  11. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  12. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  13. "github.com/mymmrac/telego"
  14. tu "github.com/mymmrac/telego/telegoutil"
  15. )
  16. const (
  17. broadcastAwaitingText = "awaiting_broadcast_text"
  18. // Pause per recipient, scaled by the copied message count, keeps the run
  19. // around the bot-wide ~30 msg/s ceiling even for whole albums.
  20. broadcastSendDelay = 60 * time.Millisecond
  21. // Progress refreshes are throttled to keep the run under rate limits.
  22. broadcastProgressEvery = 25
  23. broadcastProgressInterval = 3 * time.Second
  24. broadcastFloodRetries = 5
  25. // A long retry_after is slept in slices so cancel and bot state stay checked.
  26. broadcastFloodWaitSlice = 5 * time.Second
  27. )
  28. // broadcastDraft references the admin's original message: copyMessage relays
  29. // any message type 1:1 on behalf of the bot. An album lists its messages.
  30. type broadcastDraft struct {
  31. FromChatID int64
  32. MessageIDs []int
  33. }
  34. // broadcastResult is the end-of-run statistics shown to the admin.
  35. type broadcastResult struct {
  36. Total int
  37. Delivered int
  38. Failed int
  39. Skipped int
  40. Unreachable int
  41. Canceled bool
  42. Elapsed time.Duration
  43. }
  44. // broadcastRunner tracks the single in-flight broadcast: where to report
  45. // progress and whether the admin asked to stop it.
  46. type broadcastRunner struct {
  47. chatID int64
  48. messageID int
  49. cancel atomic.Bool
  50. mu sync.Mutex
  51. result broadcastResult
  52. }
  53. func (r *broadcastRunner) setResult(res broadcastResult) {
  54. r.mu.Lock()
  55. defer r.mu.Unlock()
  56. r.result = res
  57. }
  58. func (r *broadcastRunner) getResult() broadcastResult {
  59. r.mu.Lock()
  60. defer r.mu.Unlock()
  61. return r.result
  62. }
  63. // broadcastCompose is one admin's composition: collected message ids, the
  64. // album group still arriving, and the token binding the preview to its card.
  65. type broadcastCompose struct {
  66. messageIDs []int
  67. groupID string
  68. token string
  69. timer *time.Timer
  70. }
  71. var (
  72. broadcastMu sync.Mutex
  73. broadcastComposes = make(map[chatUser]*broadcastCompose)
  74. broadcastActive *broadcastRunner
  75. )
  76. // broadcastAlbumDebounce waits out Telegram's stream of one media group: an
  77. // album reaches the bot as separate messages sharing a media_group_id.
  78. var broadcastAlbumDebounce = 900 * time.Millisecond
  79. // errBroadcastAborted reports a recipient abandoned because the run was
  80. // cancelled or the bot stopped, which is not a delivery failure.
  81. var errBroadcastAborted = errors.New("broadcast aborted")
  82. // broadcastResetAll drops every composition and cancels the active run; the
  83. // bot calls it on stop so no timer, token or runner slot outlives the receiver.
  84. func broadcastResetAll() {
  85. broadcastMu.Lock()
  86. defer broadcastMu.Unlock()
  87. for _, c := range broadcastComposes {
  88. if c.timer != nil {
  89. c.timer.Stop()
  90. }
  91. }
  92. broadcastComposes = make(map[chatUser]*broadcastCompose)
  93. if broadcastActive != nil {
  94. broadcastActive.cancel.Store(true)
  95. broadcastActive = nil
  96. }
  97. }
  98. func broadcastDropCompose(actor chatUser) {
  99. broadcastMu.Lock()
  100. defer broadcastMu.Unlock()
  101. if c := broadcastComposes[actor]; c != nil && c.timer != nil {
  102. c.timer.Stop()
  103. }
  104. delete(broadcastComposes, actor)
  105. }
  106. // broadcastPendingDraft reports the ids awaiting an admin's confirmation;
  107. // ok is false while an album is still being collected.
  108. func broadcastPendingDraft(actor chatUser) ([]int, string, bool) {
  109. broadcastMu.Lock()
  110. defer broadcastMu.Unlock()
  111. c := broadcastComposes[actor]
  112. if c == nil || c.groupID != "" || c.token == "" {
  113. return nil, "", false
  114. }
  115. return append([]int(nil), c.messageIDs...), c.token, true
  116. }
  117. // broadcastTakePending removes the pending draft only when its card token
  118. // matches; ok is false for stale taps or admins with no pending draft.
  119. func broadcastTakePending(actor chatUser, token string) ([]int, bool) {
  120. broadcastMu.Lock()
  121. defer broadcastMu.Unlock()
  122. c := broadcastComposes[actor]
  123. if c == nil || c.token == "" || c.token != token {
  124. return nil, false
  125. }
  126. delete(broadcastComposes, actor)
  127. return c.messageIDs, true
  128. }
  129. // broadcastRegisterRunner claims the single broadcast slot; nil means one is
  130. // already running.
  131. func broadcastRegisterRunner(chatID int64) *broadcastRunner {
  132. broadcastMu.Lock()
  133. defer broadcastMu.Unlock()
  134. if broadcastActive != nil {
  135. return nil
  136. }
  137. broadcastActive = &broadcastRunner{chatID: chatID}
  138. return broadcastActive
  139. }
  140. // broadcastUnregisterRunner releases the slot only if it is still ours, so a
  141. // stale runner cannot cancel a newer one's registration.
  142. func broadcastUnregisterRunner(runner *broadcastRunner) {
  143. broadcastMu.Lock()
  144. defer broadcastMu.Unlock()
  145. if broadcastActive == runner {
  146. broadcastActive = nil
  147. }
  148. }
  149. func broadcastCurrentRunner() *broadcastRunner {
  150. broadcastMu.Lock()
  151. defer broadcastMu.Unlock()
  152. return broadcastActive
  153. }
  154. // broadcastSender delivers one draft to one chat; swapped out in tests.
  155. var broadcastSender = deliverBroadcastCopy
  156. // broadcastPause stands in for time.Sleep so tests don't wait real seconds.
  157. var broadcastPause = time.Sleep
  158. // startBroadcast answers /broadcast: one broadcast at a time, so a second
  159. // command while one is running is refused instead of queued.
  160. func (t *Tgbot) startBroadcast(actor chatUser) {
  161. chatId := actor.chatID
  162. if broadcastCurrentRunner() != nil {
  163. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAlreadyRunning"))
  164. return
  165. }
  166. broadcastDropCompose(actor)
  167. userStateMgr.set(actor, broadcastAwaitingText)
  168. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard())
  169. }
  170. // handleBroadcastInput references the message the admin sent and shows the
  171. // confirmation preview. The router hands over only the admin /broadcast awaits.
  172. func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) {
  173. logger.Debugf("broadcast: chat %d input (message_id=%d group=%q)", actor.chatID, message.MessageID, message.MediaGroupID)
  174. if message.MediaGroupID == "" {
  175. broadcastDropCompose(actor)
  176. t.acceptBroadcastDraft(actor, []int{message.MessageID})
  177. return
  178. }
  179. t.bufferBroadcastMedia(actor, message.MediaGroupID, message.MessageID)
  180. }
  181. // acceptBroadcastDraft validates the draft with a self-copy — the admin sees
  182. // exactly what recipients will get — and shows the confirmation preview.
  183. func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) {
  184. chatId := actor.chatID
  185. if err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids}); err != nil {
  186. broadcastDropCompose(actor)
  187. userStateMgr.clear(actor)
  188. logger.Warningf("broadcast: chat %d message %v cannot be copied: %v", chatId, ids, err)
  189. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastNotCopyable"))
  190. return
  191. }
  192. recipients := t.collectBroadcastRecipients()
  193. token := t.randomLowerAndNum(12)
  194. broadcastMu.Lock()
  195. broadcastComposes[actor] = &broadcastCompose{messageIDs: ids, token: token}
  196. broadcastMu.Unlock()
  197. userStateMgr.clear(actor)
  198. keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
  199. tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.broadcastSend")).WithCallbackData("broadcast_confirm "+token),
  200. tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"),
  201. ))
  202. t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastPreview", "Count=="+strconv.Itoa(len(recipients))), keyboard)
  203. }
  204. // bufferBroadcastMedia appends an album item; the debounce timer fires the
  205. // preview once the group stops growing.
  206. func (t *Tgbot) bufferBroadcastMedia(actor chatUser, groupID string, messageID int) {
  207. broadcastMu.Lock()
  208. c := broadcastComposes[actor]
  209. if c == nil || c.groupID != groupID {
  210. if c != nil && c.timer != nil {
  211. c.timer.Stop()
  212. }
  213. c = &broadcastCompose{groupID: groupID}
  214. c.timer = time.AfterFunc(broadcastAlbumDebounce, func() {
  215. t.finalizeBroadcastAlbum(actor, groupID)
  216. })
  217. broadcastComposes[actor] = c
  218. }
  219. c.messageIDs = append(c.messageIDs, messageID)
  220. c.timer.Reset(broadcastAlbumDebounce)
  221. broadcastMu.Unlock()
  222. }
  223. func (t *Tgbot) finalizeBroadcastAlbum(actor chatUser, groupID string) {
  224. broadcastMu.Lock()
  225. c := broadcastComposes[actor]
  226. if c == nil || c.groupID != groupID {
  227. broadcastMu.Unlock()
  228. return
  229. }
  230. // Album updates can arrive out of order, and copyMessages requires
  231. // strictly increasing ids.
  232. ids := append([]int(nil), c.messageIDs...)
  233. slices.Sort(ids)
  234. ids = slices.Compact(ids)
  235. delete(broadcastComposes, actor)
  236. broadcastMu.Unlock()
  237. t.acceptBroadcastDraft(actor, ids)
  238. }
  239. func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup {
  240. return tu.InlineKeyboard(tu.InlineKeyboardRow(
  241. tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"),
  242. ))
  243. }
  244. // confirmBroadcast turns the pending draft into a run: it claims the single
  245. // runner slot, replaces the preview with a progress card, and starts delivery.
  246. func (t *Tgbot) confirmBroadcast(actor chatUser, token string, messageID int, queryID string) {
  247. chatId := actor.chatID
  248. runner := broadcastRegisterRunner(chatId)
  249. if runner == nil {
  250. t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.messages.broadcastAlreadyRunning"))
  251. return
  252. }
  253. ids, ok := broadcastTakePending(actor, token)
  254. if !ok {
  255. broadcastUnregisterRunner(runner)
  256. t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.wentWrong"))
  257. return
  258. }
  259. draft := broadcastDraft{FromChatID: chatId, MessageIDs: ids}
  260. recipients := t.collectBroadcastRecipients()
  261. if len(recipients) == 0 {
  262. broadcastUnregisterRunner(runner)
  263. t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.messages.broadcastNoRecipients"))
  264. t.deleteMessageTgBot(chatId, messageID)
  265. return
  266. }
  267. runner.messageID = messageID
  268. t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastStarted"))
  269. t.editMessageTgBot(chatId, messageID, t.broadcastProgressText(0, len(recipients), 0, 0), t.broadcastCancelKeyboard())
  270. common.GoRecover("tgbot-broadcast", func() {
  271. t.runBroadcast(runner, draft, recipients)
  272. })
  273. }
  274. // runBroadcast walks the recipients sequentially, honoring rate limits, and
  275. // reports the final summary on the runner's card.
  276. func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, recipients []int64) {
  277. defer broadcastUnregisterRunner(runner)
  278. start := time.Now()
  279. // One copyMessages call carries a whole album, so the pause scales with
  280. // the batch size to stay under the same per-second ceiling.
  281. pause := broadcastSendDelay * time.Duration(max(1, len(draft.MessageIDs)))
  282. aborted := func() bool { return runner.cancel.Load() || !t.IsRunning() }
  283. sent, failed, unreachable := 0, 0, 0
  284. lastProgress := time.Now()
  285. canceled := false
  286. for i, chatID := range recipients {
  287. if aborted() {
  288. canceled = runner.cancel.Load()
  289. break
  290. }
  291. err := broadcastDeliverOne(chatID, draft, aborted)
  292. if errors.Is(err, errBroadcastAborted) {
  293. canceled = runner.cancel.Load()
  294. break
  295. }
  296. switch {
  297. case err == nil:
  298. sent++
  299. case broadcastChatUnreachable(err):
  300. // 403 means the chat never started the bot or blocked it; a long
  301. // recipient list would turn these into log spam at warning level.
  302. unreachable++
  303. logger.Debugf("broadcast: chat %d cannot receive bot messages: %v", chatID, err)
  304. default:
  305. failed++
  306. logger.Warningf("broadcast: chat %d not delivered: %v", chatID, err)
  307. }
  308. done := i + 1
  309. if done%broadcastProgressEvery == 0 || time.Since(lastProgress) >= broadcastProgressInterval {
  310. t.editMessageTgBot(runner.chatID, runner.messageID, t.broadcastProgressText(done, len(recipients), sent, failed), t.broadcastCancelKeyboard())
  311. lastProgress = time.Now()
  312. }
  313. if i < len(recipients)-1 {
  314. broadcastPause(pause)
  315. }
  316. }
  317. result := broadcastResult{
  318. Total: len(recipients),
  319. Delivered: sent,
  320. Failed: failed,
  321. Skipped: len(recipients) - sent - failed,
  322. Unreachable: unreachable,
  323. Canceled: canceled,
  324. Elapsed: time.Since(start).Round(time.Second),
  325. }
  326. summary := t.broadcastSummaryText(result)
  327. if !t.finalizeBroadcastCard(runner, summary) {
  328. t.SendMsgToTgbot(runner.chatID, summary)
  329. }
  330. logger.Info("broadcast finished: delivered", sent, "failed", failed, "skipped", result.Skipped, "elapsed", result.Elapsed)
  331. runner.setResult(result)
  332. }
  333. func (t *Tgbot) broadcastProgressText(done, total, sent, failed int) string {
  334. return t.I18nBot("tgbot.messages.broadcastProgress",
  335. "Done=="+strconv.Itoa(done),
  336. "Total=="+strconv.Itoa(total),
  337. "Sent=="+strconv.Itoa(sent),
  338. "Failed=="+strconv.Itoa(failed))
  339. }
  340. func (t *Tgbot) broadcastSummaryText(result broadcastResult) string {
  341. params := []string{
  342. "Total==" + strconv.Itoa(result.Total),
  343. "Sent==" + strconv.Itoa(result.Delivered),
  344. "Failed==" + strconv.Itoa(result.Failed),
  345. "Skipped==" + strconv.Itoa(result.Skipped),
  346. "Time==" + result.Elapsed.String(),
  347. }
  348. summary := t.I18nBot("tgbot.messages.broadcastFinished", params...)
  349. if result.Canceled {
  350. summary = t.I18nBot("tgbot.messages.broadcastCanceled", params...)
  351. }
  352. if result.Unreachable > 0 {
  353. summary += t.I18nBot("tgbot.messages.broadcastUnreachable", "Count=="+strconv.Itoa(result.Unreachable))
  354. }
  355. return summary
  356. }
  357. // finalizeBroadcastCard turns the progress card into the summary; false means
  358. // the card is gone and the summary needs its own message to be seen at all.
  359. func (t *Tgbot) finalizeBroadcastCard(runner *broadcastRunner, summary string) bool {
  360. params := telego.EditMessageTextParams{
  361. ChatID: tu.ID(runner.chatID),
  362. MessageID: runner.messageID,
  363. Text: summary,
  364. ParseMode: "HTML",
  365. ReplyMarkup: &telego.InlineKeyboardMarkup{InlineKeyboard: [][]telego.InlineKeyboardButton{}},
  366. }
  367. _, err := bot.EditMessageText(context.Background(), &params)
  368. if err == nil || isTelegramNotModifiedError(err) {
  369. return true
  370. }
  371. logger.Warning("broadcast: progress card edit failed:", err)
  372. return false
  373. }
  374. // cancelBroadcast handles the inline cancel button: while composing it drops
  375. // the draft, while running it stops the loop after the current recipient.
  376. func (t *Tgbot) cancelBroadcast(actor chatUser, messageID int, queryID string) {
  377. chatId := actor.chatID
  378. if runner := broadcastCurrentRunner(); runner != nil && runner.chatID == chatId {
  379. runner.cancel.Store(true)
  380. t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceling"))
  381. return
  382. }
  383. broadcastDropCompose(actor)
  384. userStateMgr.clear(actor)
  385. t.deleteMessageTgBot(chatId, messageID)
  386. t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceled"))
  387. }
  388. // collectBroadcastRecipients returns the distinct client Telegram IDs to
  389. // deliver to: clients with a linked tg_id, admins excluded.
  390. func (t *Tgbot) collectBroadcastRecipients() []int64 {
  391. inbounds, err := t.inboundService.GetAllInbounds()
  392. if err != nil {
  393. logger.Warning("broadcast: unable to load inbounds:", err)
  394. return nil
  395. }
  396. seen := make(map[int64]bool)
  397. var recipients []int64
  398. for _, inbound := range inbounds {
  399. clients, err := t.inboundService.GetClients(inbound)
  400. if err != nil {
  401. continue
  402. }
  403. for _, client := range clients {
  404. if client.TgID == 0 || seen[client.TgID] || checkAdmin(client.TgID) {
  405. continue
  406. }
  407. seen[client.TgID] = true
  408. recipients = append(recipients, client.TgID)
  409. }
  410. }
  411. return recipients
  412. }
  413. // broadcastDeliverOne retries a recipient through flood-control waits so a 429
  414. // never drops them; after broadcastFloodRetries waits it gives up on them.
  415. func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) error {
  416. for attempt := 0; ; attempt++ {
  417. err := broadcastSender(chatID, draft)
  418. if err == nil {
  419. return nil
  420. }
  421. wait, flood := broadcastRetryAfter(err)
  422. if !flood || attempt >= broadcastFloodRetries {
  423. return err
  424. }
  425. logger.Warningf("broadcast: chat %d is flood-limited, retrying in %s", chatID, wait)
  426. if !broadcastFloodWait(wait, aborted) {
  427. return errBroadcastAborted
  428. }
  429. }
  430. }
  431. // broadcastFloodWait sleeps out a flood-control delay in slices so a cancel or
  432. // a bot stop ends the wait instead of parking the runner slot for minutes.
  433. func broadcastFloodWait(wait time.Duration, aborted func() bool) bool {
  434. for remaining := wait; remaining > 0; remaining -= broadcastFloodWaitSlice {
  435. if aborted != nil && aborted() {
  436. return false
  437. }
  438. broadcastPause(min(broadcastFloodWaitSlice, remaining))
  439. }
  440. return aborted == nil || !aborted()
  441. }
  442. // broadcastChatUnreachable reports a Telegram 403: the chat never started the
  443. // bot or has blocked it, which no retry can fix.
  444. func broadcastChatUnreachable(err error) bool {
  445. var apiErr *telegoapi.Error
  446. return errors.As(err, &apiErr) && apiErr.ErrorCode == 403
  447. }
  448. // broadcastRetryAfter reports the flood-control wait a 429 response asks for.
  449. func broadcastRetryAfter(err error) (time.Duration, bool) {
  450. var apiErr *telegoapi.Error
  451. if !errors.As(err, &apiErr) || apiErr.ErrorCode != 429 {
  452. return 0, false
  453. }
  454. if apiErr.Parameters == nil || apiErr.Parameters.RetryAfter <= 0 {
  455. return time.Second, true
  456. }
  457. return time.Duration(apiErr.Parameters.RetryAfter) * time.Second, true
  458. }
  459. // deliverBroadcastCopy copies the admin's message to one recipient chat; an
  460. // album rides one copyMessages call and arrives with no forward header.
  461. func deliverBroadcastCopy(chatID int64, draft broadcastDraft) error {
  462. from := tu.ID(draft.FromChatID)
  463. return callTelegramAPI(func(ctx context.Context) error {
  464. var err error
  465. switch {
  466. case len(draft.MessageIDs) > 1:
  467. _, err = bot.CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs})
  468. case len(draft.MessageIDs) == 1:
  469. _, err = bot.CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]})
  470. }
  471. return err
  472. })
  473. }
  474. func callTelegramAPI(call func(ctx context.Context) error) error {
  475. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  476. defer cancel()
  477. return call(ctx)
  478. }