diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go index 247efc6c8..4f6c509a0 100644 --- a/internal/web/service/tgbot/tgbot.go +++ b/internal/web/service/tgbot/tgbot.go @@ -408,6 +408,7 @@ func (t *Tgbot) trySetBotCommands(bot *telego.Bot) { {Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")}, {Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")}, {Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")}, + {Command: "broadcast", Description: t.I18nBot("tgbot.commands.broadcastDesc")}, }, }) if err != nil { @@ -542,6 +543,7 @@ func StopBot() { userStateMgr.reset() addClientDrafts.resetAll() + broadcastResetAll() if handler != nil { _ = handler.Stop() diff --git a/internal/web/service/tgbot/tgbot_broadcast.go b/internal/web/service/tgbot/tgbot_broadcast.go new file mode 100644 index 000000000..d4bf6ecf1 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_broadcast.go @@ -0,0 +1,525 @@ +package tgbot + +import ( + "context" + "errors" + "slices" + "strconv" + "sync" + "sync/atomic" + "time" + + telegoapi "github.com/mymmrac/telego/telegoapi" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +const ( + broadcastAwaitingText = "awaiting_broadcast_text" + + // Pause per recipient, scaled by the copied message count, keeps the run + // around the bot-wide ~30 msg/s ceiling even for whole albums. + broadcastSendDelay = 60 * time.Millisecond + // Progress refreshes are throttled to keep the run under rate limits. + broadcastProgressEvery = 25 + broadcastProgressInterval = 3 * time.Second + broadcastFloodRetries = 5 + // A long retry_after is slept in slices so cancel and bot state stay checked. + broadcastFloodWaitSlice = 5 * time.Second +) + +// broadcastDraft references the admin's original message: copyMessage relays +// any message type 1:1 on behalf of the bot. An album lists its messages. +type broadcastDraft struct { + FromChatID int64 + MessageIDs []int +} + +// broadcastResult is the end-of-run statistics shown to the admin. +type broadcastResult struct { + Total int + Delivered int + Failed int + Skipped int + Unreachable int + Canceled bool + Elapsed time.Duration +} + +// broadcastRunner tracks the single in-flight broadcast: where to report +// progress and whether the admin asked to stop it. +type broadcastRunner struct { + chatID int64 + messageID int + cancel atomic.Bool + + mu sync.Mutex + result broadcastResult +} + +func (r *broadcastRunner) setResult(res broadcastResult) { + r.mu.Lock() + defer r.mu.Unlock() + r.result = res +} + +func (r *broadcastRunner) getResult() broadcastResult { + r.mu.Lock() + defer r.mu.Unlock() + return r.result +} + +// broadcastCompose is one admin's composition: collected message ids, the +// album group still arriving, and the token binding the preview to its card. +type broadcastCompose struct { + messageIDs []int + groupID string + token string + timer *time.Timer +} + +var ( + broadcastMu sync.Mutex + broadcastComposes = make(map[chatUser]*broadcastCompose) + broadcastActive *broadcastRunner +) + +// broadcastAlbumDebounce waits out Telegram's stream of one media group: an +// album reaches the bot as separate messages sharing a media_group_id. +var broadcastAlbumDebounce = 900 * time.Millisecond + +// errBroadcastAborted reports a recipient abandoned because the run was +// cancelled or the bot stopped, which is not a delivery failure. +var errBroadcastAborted = errors.New("broadcast aborted") + +// broadcastResetAll drops every composition and cancels the active run; the +// bot calls it on stop so no timer, token or runner slot outlives the receiver. +func broadcastResetAll() { + broadcastMu.Lock() + defer broadcastMu.Unlock() + for _, c := range broadcastComposes { + if c.timer != nil { + c.timer.Stop() + } + } + broadcastComposes = make(map[chatUser]*broadcastCompose) + if broadcastActive != nil { + broadcastActive.cancel.Store(true) + broadcastActive = nil + } +} + +func broadcastDropCompose(actor chatUser) { + broadcastMu.Lock() + defer broadcastMu.Unlock() + if c := broadcastComposes[actor]; c != nil && c.timer != nil { + c.timer.Stop() + } + delete(broadcastComposes, actor) +} + +// broadcastPendingDraft reports the ids awaiting an admin's confirmation; +// ok is false while an album is still being collected. +func broadcastPendingDraft(actor chatUser) ([]int, string, bool) { + broadcastMu.Lock() + defer broadcastMu.Unlock() + c := broadcastComposes[actor] + if c == nil || c.groupID != "" || c.token == "" { + return nil, "", false + } + return append([]int(nil), c.messageIDs...), c.token, true +} + +// broadcastTakePending removes the pending draft only when its card token +// matches; ok is false for stale taps or admins with no pending draft. +func broadcastTakePending(actor chatUser, token string) ([]int, bool) { + broadcastMu.Lock() + defer broadcastMu.Unlock() + c := broadcastComposes[actor] + if c == nil || c.token == "" || c.token != token { + return nil, false + } + delete(broadcastComposes, actor) + return c.messageIDs, true +} + +// broadcastRegisterRunner claims the single broadcast slot; nil means one is +// already running. +func broadcastRegisterRunner(chatID int64) *broadcastRunner { + broadcastMu.Lock() + defer broadcastMu.Unlock() + if broadcastActive != nil { + return nil + } + broadcastActive = &broadcastRunner{chatID: chatID} + return broadcastActive +} + +// broadcastUnregisterRunner releases the slot only if it is still ours, so a +// stale runner cannot cancel a newer one's registration. +func broadcastUnregisterRunner(runner *broadcastRunner) { + broadcastMu.Lock() + defer broadcastMu.Unlock() + if broadcastActive == runner { + broadcastActive = nil + } +} + +func broadcastCurrentRunner() *broadcastRunner { + broadcastMu.Lock() + defer broadcastMu.Unlock() + return broadcastActive +} + +// broadcastSender delivers one draft to one chat; swapped out in tests. +var broadcastSender = deliverBroadcastCopy + +// broadcastPause stands in for time.Sleep so tests don't wait real seconds. +var broadcastPause = time.Sleep + +// startBroadcast answers /broadcast: one broadcast at a time, so a second +// command while one is running is refused instead of queued. +func (t *Tgbot) startBroadcast(actor chatUser) { + chatId := actor.chatID + if broadcastCurrentRunner() != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAlreadyRunning")) + return + } + broadcastDropCompose(actor) + userStateMgr.set(actor, broadcastAwaitingText) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard()) +} + +// handleBroadcastInput references the message the admin sent and shows the +// confirmation preview. The router hands over only the admin /broadcast awaits. +func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) { + logger.Debugf("broadcast: chat %d input (message_id=%d group=%q)", actor.chatID, message.MessageID, message.MediaGroupID) + if message.MediaGroupID == "" { + broadcastDropCompose(actor) + t.acceptBroadcastDraft(actor, []int{message.MessageID}) + return + } + t.bufferBroadcastMedia(actor, message.MediaGroupID, message.MessageID) +} + +// acceptBroadcastDraft validates the draft with a self-copy — the admin sees +// exactly what recipients will get — and shows the confirmation preview. +func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) { + chatId := actor.chatID + if err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids}); err != nil { + broadcastDropCompose(actor) + userStateMgr.clear(actor) + logger.Warningf("broadcast: chat %d message %v cannot be copied: %v", chatId, ids, err) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastNotCopyable")) + return + } + recipients := t.collectBroadcastRecipients() + token := t.randomLowerAndNum(12) + broadcastMu.Lock() + broadcastComposes[actor] = &broadcastCompose{messageIDs: ids, token: token} + broadcastMu.Unlock() + userStateMgr.clear(actor) + keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.broadcastSend")).WithCallbackData("broadcast_confirm "+token), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"), + )) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastPreview", "Count=="+strconv.Itoa(len(recipients))), keyboard) +} + +// bufferBroadcastMedia appends an album item; the debounce timer fires the +// preview once the group stops growing. +func (t *Tgbot) bufferBroadcastMedia(actor chatUser, groupID string, messageID int) { + broadcastMu.Lock() + c := broadcastComposes[actor] + if c == nil || c.groupID != groupID { + if c != nil && c.timer != nil { + c.timer.Stop() + } + c = &broadcastCompose{groupID: groupID} + c.timer = time.AfterFunc(broadcastAlbumDebounce, func() { + t.finalizeBroadcastAlbum(actor, groupID) + }) + broadcastComposes[actor] = c + } + c.messageIDs = append(c.messageIDs, messageID) + c.timer.Reset(broadcastAlbumDebounce) + broadcastMu.Unlock() +} + +func (t *Tgbot) finalizeBroadcastAlbum(actor chatUser, groupID string) { + broadcastMu.Lock() + c := broadcastComposes[actor] + if c == nil || c.groupID != groupID { + broadcastMu.Unlock() + return + } + // Album updates can arrive out of order, and copyMessages requires + // strictly increasing ids. + ids := append([]int(nil), c.messageIDs...) + slices.Sort(ids) + ids = slices.Compact(ids) + delete(broadcastComposes, actor) + broadcastMu.Unlock() + + t.acceptBroadcastDraft(actor, ids) +} + +func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup { + return tu.InlineKeyboard(tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"), + )) +} + +// confirmBroadcast turns the pending draft into a run: it claims the single +// runner slot, replaces the preview with a progress card, and starts delivery. +func (t *Tgbot) confirmBroadcast(actor chatUser, token string, messageID int, queryID string) { + chatId := actor.chatID + runner := broadcastRegisterRunner(chatId) + if runner == nil { + t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.messages.broadcastAlreadyRunning")) + return + } + ids, ok := broadcastTakePending(actor, token) + if !ok { + broadcastUnregisterRunner(runner) + t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.wentWrong")) + return + } + draft := broadcastDraft{FromChatID: chatId, MessageIDs: ids} + recipients := t.collectBroadcastRecipients() + if len(recipients) == 0 { + broadcastUnregisterRunner(runner) + t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.messages.broadcastNoRecipients")) + t.deleteMessageTgBot(chatId, messageID) + return + } + runner.messageID = messageID + t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastStarted")) + t.editMessageTgBot(chatId, messageID, t.broadcastProgressText(0, len(recipients), 0, 0), t.broadcastCancelKeyboard()) + common.GoRecover("tgbot-broadcast", func() { + t.runBroadcast(runner, draft, recipients) + }) +} + +// runBroadcast walks the recipients sequentially, honoring rate limits, and +// reports the final summary on the runner's card. +func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, recipients []int64) { + defer broadcastUnregisterRunner(runner) + start := time.Now() + // One copyMessages call carries a whole album, so the pause scales with + // the batch size to stay under the same per-second ceiling. + pause := broadcastSendDelay * time.Duration(max(1, len(draft.MessageIDs))) + aborted := func() bool { return runner.cancel.Load() || !t.IsRunning() } + sent, failed, unreachable := 0, 0, 0 + lastProgress := time.Now() + canceled := false + + for i, chatID := range recipients { + if aborted() { + canceled = runner.cancel.Load() + break + } + err := broadcastDeliverOne(chatID, draft, aborted) + if errors.Is(err, errBroadcastAborted) { + canceled = runner.cancel.Load() + break + } + switch { + case err == nil: + sent++ + case broadcastChatUnreachable(err): + // 403 means the chat never started the bot or blocked it; a long + // recipient list would turn these into log spam at warning level. + unreachable++ + logger.Debugf("broadcast: chat %d cannot receive bot messages: %v", chatID, err) + default: + failed++ + logger.Warningf("broadcast: chat %d not delivered: %v", chatID, err) + } + done := i + 1 + if done%broadcastProgressEvery == 0 || time.Since(lastProgress) >= broadcastProgressInterval { + t.editMessageTgBot(runner.chatID, runner.messageID, t.broadcastProgressText(done, len(recipients), sent, failed), t.broadcastCancelKeyboard()) + lastProgress = time.Now() + } + if i < len(recipients)-1 { + broadcastPause(pause) + } + } + + result := broadcastResult{ + Total: len(recipients), + Delivered: sent, + Failed: failed, + Skipped: len(recipients) - sent - failed, + Unreachable: unreachable, + Canceled: canceled, + Elapsed: time.Since(start).Round(time.Second), + } + summary := t.broadcastSummaryText(result) + if !t.finalizeBroadcastCard(runner, summary) { + t.SendMsgToTgbot(runner.chatID, summary) + } + logger.Info("broadcast finished: delivered", sent, "failed", failed, "skipped", result.Skipped, "elapsed", result.Elapsed) + runner.setResult(result) +} + +func (t *Tgbot) broadcastProgressText(done, total, sent, failed int) string { + return t.I18nBot("tgbot.messages.broadcastProgress", + "Done=="+strconv.Itoa(done), + "Total=="+strconv.Itoa(total), + "Sent=="+strconv.Itoa(sent), + "Failed=="+strconv.Itoa(failed)) +} + +func (t *Tgbot) broadcastSummaryText(result broadcastResult) string { + params := []string{ + "Total==" + strconv.Itoa(result.Total), + "Sent==" + strconv.Itoa(result.Delivered), + "Failed==" + strconv.Itoa(result.Failed), + "Skipped==" + strconv.Itoa(result.Skipped), + "Time==" + result.Elapsed.String(), + } + summary := t.I18nBot("tgbot.messages.broadcastFinished", params...) + if result.Canceled { + summary = t.I18nBot("tgbot.messages.broadcastCanceled", params...) + } + if result.Unreachable > 0 { + summary += t.I18nBot("tgbot.messages.broadcastUnreachable", "Count=="+strconv.Itoa(result.Unreachable)) + } + return summary +} + +// finalizeBroadcastCard turns the progress card into the summary; false means +// the card is gone and the summary needs its own message to be seen at all. +func (t *Tgbot) finalizeBroadcastCard(runner *broadcastRunner, summary string) bool { + params := telego.EditMessageTextParams{ + ChatID: tu.ID(runner.chatID), + MessageID: runner.messageID, + Text: summary, + ParseMode: "HTML", + ReplyMarkup: &telego.InlineKeyboardMarkup{InlineKeyboard: [][]telego.InlineKeyboardButton{}}, + } + _, err := bot.EditMessageText(context.Background(), ¶ms) + if err == nil || isTelegramNotModifiedError(err) { + return true + } + logger.Warning("broadcast: progress card edit failed:", err) + return false +} + +// cancelBroadcast handles the inline cancel button: while composing it drops +// the draft, while running it stops the loop after the current recipient. +func (t *Tgbot) cancelBroadcast(actor chatUser, messageID int, queryID string) { + chatId := actor.chatID + if runner := broadcastCurrentRunner(); runner != nil && runner.chatID == chatId { + runner.cancel.Store(true) + t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceling")) + return + } + broadcastDropCompose(actor) + userStateMgr.clear(actor) + t.deleteMessageTgBot(chatId, messageID) + t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceled")) +} + +// collectBroadcastRecipients returns the distinct client Telegram IDs to +// deliver to: clients with a linked tg_id, admins excluded. +func (t *Tgbot) collectBroadcastRecipients() []int64 { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("broadcast: unable to load inbounds:", err) + return nil + } + seen := make(map[int64]bool) + var recipients []int64 + for _, inbound := range inbounds { + clients, err := t.inboundService.GetClients(inbound) + if err != nil { + continue + } + for _, client := range clients { + if client.TgID == 0 || seen[client.TgID] || checkAdmin(client.TgID) { + continue + } + seen[client.TgID] = true + recipients = append(recipients, client.TgID) + } + } + return recipients +} + +// broadcastDeliverOne retries a recipient through flood-control waits so a 429 +// never drops them; after broadcastFloodRetries waits it gives up on them. +func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) error { + for attempt := 0; ; attempt++ { + err := broadcastSender(chatID, draft) + if err == nil { + return nil + } + wait, flood := broadcastRetryAfter(err) + if !flood || attempt >= broadcastFloodRetries { + return err + } + logger.Warningf("broadcast: chat %d is flood-limited, retrying in %s", chatID, wait) + if !broadcastFloodWait(wait, aborted) { + return errBroadcastAborted + } + } +} + +// broadcastFloodWait sleeps out a flood-control delay in slices so a cancel or +// a bot stop ends the wait instead of parking the runner slot for minutes. +func broadcastFloodWait(wait time.Duration, aborted func() bool) bool { + for remaining := wait; remaining > 0; remaining -= broadcastFloodWaitSlice { + if aborted != nil && aborted() { + return false + } + broadcastPause(min(broadcastFloodWaitSlice, remaining)) + } + return aborted == nil || !aborted() +} + +// broadcastChatUnreachable reports a Telegram 403: the chat never started the +// bot or has blocked it, which no retry can fix. +func broadcastChatUnreachable(err error) bool { + var apiErr *telegoapi.Error + return errors.As(err, &apiErr) && apiErr.ErrorCode == 403 +} + +// broadcastRetryAfter reports the flood-control wait a 429 response asks for. +func broadcastRetryAfter(err error) (time.Duration, bool) { + var apiErr *telegoapi.Error + if !errors.As(err, &apiErr) || apiErr.ErrorCode != 429 { + return 0, false + } + if apiErr.Parameters == nil || apiErr.Parameters.RetryAfter <= 0 { + return time.Second, true + } + return time.Duration(apiErr.Parameters.RetryAfter) * time.Second, true +} + +// deliverBroadcastCopy copies the admin's message to one recipient chat; an +// album rides one copyMessages call and arrives with no forward header. +func deliverBroadcastCopy(chatID int64, draft broadcastDraft) error { + from := tu.ID(draft.FromChatID) + return callTelegramAPI(func(ctx context.Context) error { + var err error + switch { + case len(draft.MessageIDs) > 1: + _, err = bot.CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs}) + case len(draft.MessageIDs) == 1: + _, err = bot.CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]}) + } + return err + }) +} + +func callTelegramAPI(call func(ctx context.Context) error) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return call(ctx) +} diff --git a/internal/web/service/tgbot/tgbot_broadcast_test.go b/internal/web/service/tgbot/tgbot_broadcast_test.go new file mode 100644 index 000000000..c904fb82f --- /dev/null +++ b/internal/web/service/tgbot/tgbot_broadcast_test.go @@ -0,0 +1,941 @@ +package tgbot + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" + + telegoapi "github.com/mymmrac/telego/telegoapi" + + "github.com/mymmrac/telego" + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +// newBroadcastMock serves ok:true and records per-method call counts and +// bodies; copyMessages answers with an array of ids, as the real API does. +func newBroadcastMock(t *testing.T) (url string, calls func(string) int, bodies func(string) []map[string]any) { + t.Helper() + var mu sync.Mutex + counts := map[string]int{} + sent := map[string][]map[string]any{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + payload := map[string]any{} + _ = json.Unmarshal(raw, &payload) + method := strings.TrimPrefix(r.URL.Path, "/bot"+testBotToken+"/") + message := map[string]any{"message_id": 7, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}} + result := any(message) + if method == "copyMessages" { + result = []any{message, message} + } + mu.Lock() + counts[method]++ + sent[method] = append(sent[method], payload) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result}) + })) + t.Cleanup(srv.Close) + return srv.URL, + func(method string) int { + mu.Lock() + defer mu.Unlock() + return counts[method] + }, + func(method string) []map[string]any { + mu.Lock() + defer mu.Unlock() + return append([]map[string]any(nil), sent[method]...) + } +} + +func setBroadcastAdmins(t *testing.T, ids []int64) { + t.Helper() + tgBotMutex.Lock() + orig := adminIds + adminIds = ids + tgBotMutex.Unlock() + t.Cleanup(func() { + tgBotMutex.Lock() + adminIds = orig + tgBotMutex.Unlock() + }) +} + +func setBroadcastRunning(t *testing.T, running bool) { + t.Helper() + tgBotMutex.Lock() + orig := isRunning + isRunning = running + tgBotMutex.Unlock() + t.Cleanup(func() { + tgBotMutex.Lock() + isRunning = orig + tgBotMutex.Unlock() + }) +} + +func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) error, pause func(time.Duration)) { + t.Helper() + origSend, origPause := broadcastSender, broadcastPause + t.Cleanup(func() { + broadcastSender, broadcastPause = origSend, origPause + }) + broadcastSender = sender + if pause != nil { + broadcastPause = pause + } +} + +// broadcastLocalizer renders the broadcast keys a test asserts on; without it +// I18n returns the bare key instead of the template output. +func broadcastLocalizer(t *testing.T) { + t.Helper() + bundle := i18n.NewBundle(language.MustParse("en-US")) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + _ = bundle.AddMessages(language.MustParse("en-US"), + &i18n.Message{ID: "tgbot.messages.broadcastPreview", Other: "📤 This message will go to {{ .Count }} recipients. Send it?"}, + &i18n.Message{ID: "tgbot.messages.broadcastNotCopyable", Other: "❗ This message can't be copied for broadcast."}, + &i18n.Message{ID: "tgbot.messages.broadcastAskText", Other: "send the message"}, + &i18n.Message{ID: "tgbot.messages.broadcastAlreadyRunning", Other: "already running"}, + &i18n.Message{ID: "tgbot.messages.broadcastProgress", Other: "progress {{ .Sent }}/{{ .Total }} failed {{ .Failed }}"}, + &i18n.Message{ID: "tgbot.messages.broadcastFinished", Other: "finished"}, + &i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"}, + &i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."}, + ) + orig := locale.LocalizerBot + t.Cleanup(func() { locale.LocalizerBot = orig }) + locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") +} + +func createBroadcastInbound(t *testing.T, tag, settings string) { + t.Helper() + inbound := &model.Inbound{Tag: tag, Settings: settings, Enable: true} + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("create inbound %s: %v", tag, err) + } +} + +// broadcastClientsJSON renders an inbound settings blob with the given tgIds. +func broadcastClientsJSON(t *testing.T, tgIDs ...int64) string { + t.Helper() + clients := make([]string, 0, len(tgIDs)) + for i, tgID := range tgIDs { + clients = append(clients, fmt.Sprintf(`{"email":"user%d@x","tgId":%d}`, i, tgID)) + } + return `{"clients":[` + strings.Join(clients, ",") + `]}` +} + +// resetBroadcastState clears the shared broadcast globals before a test +// asserts on them: shuffled tests may inherit state from an earlier test. +func resetBroadcastState(t *testing.T) { + t.Helper() + broadcastResetAll() + userStateMgr.reset() +} + +// composeBroadcast hands a message to the composition step the way the router +// does: keyed by its sender, who is awaiting broadcast input. +func composeBroadcast(tb *Tgbot, message telego.Message) { + actor := messageActor(message) + userStateMgr.set(actor, broadcastAwaitingText) + tb.handleBroadcastInput(&message, actor) +} + +func swapAlbumDebounce(t *testing.T, d time.Duration) { + t.Helper() + orig := broadcastAlbumDebounce + t.Cleanup(func() { broadcastAlbumDebounce = orig }) + broadcastAlbumDebounce = d +} + +// waitBroadcastPending polls until the debounce finalizer has stored a draft +// and returns its ids and card token. +func waitBroadcastPending(t *testing.T, actor chatUser) ([]int, string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if ids, token, ok := broadcastPendingDraft(actor); ok { + return ids, token + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("album draft was never finalized in time") + return nil, "" +} + +func TestCollectBroadcastRecipients(t *testing.T) { + tb := newStaleButtonTgbot(t) + setBroadcastAdmins(t, []int64{222}) + + // 111 appears on both inbounds, 222 is an admin, 0 has no Telegram ID. + createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 111, 111, 222)) + createBroadcastInbound(t, "in-2", broadcastClientsJSON(t, 111, 333, 0, 444)) + + got := tb.collectBroadcastRecipients() + slices.Sort(got) + if !slices.Equal(got, []int64{111, 333, 444}) { + t.Fatalf("collectBroadcastRecipients() = %v, want [111 333 444]", got) + } +} + +func assertBroadcastResult(t *testing.T, got, want broadcastResult) { + t.Helper() + got.Elapsed, want.Elapsed = 0, 0 + if got != want { + t.Errorf("result = %+v, want %+v", got, want) + } +} + +func TestRunBroadcastCounters(t *testing.T) { + broadcastLocalizer(t) + url, _, _ := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot was blocked by the user"} + + tests := []struct { + name string + recipients []int64 + outcomes map[int64]error + delivered int + failed int + skipped int + unreachable int + }{ + {"all delivered", []int64{1, 2, 3}, map[int64]error{1: nil, 2: nil, 3: nil}, 3, 0, 0, 0}, + { + "a blocked chat is skipped and a transient error fails", + []int64{1, 2, 3, 4}, + map[int64]error{1: nil, 2: blocked, 3: nil, 4: errors.New("connection reset")}, + 2, 1, 1, 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error { + return tt.outcomes[chatID] + }, func(time.Duration) {}) + + runner := &broadcastRunner{chatID: 100, messageID: 5} + tb := &Tgbot{} + tb.runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, tt.recipients) + + assertBroadcastResult(t, runner.getResult(), broadcastResult{ + Total: len(tt.recipients), + Delivered: tt.delivered, + Failed: tt.failed, + Skipped: tt.skipped, + Unreachable: tt.unreachable, + }) + summary := tb.broadcastSummaryText(runner.getResult()) + if tt.unreachable == 0 { + if strings.Contains(summary, "press Start") { + t.Errorf("summary = %q, want no unreachable note", summary) + } + } else if !strings.Contains(summary, "1 recipients cannot be messaged") { + t.Errorf("summary = %q, want the unreachable note with the count", summary) + } + }) + } +} + +func TestRunBroadcastCancelsMidway(t *testing.T) { + url, _, _ := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + + runner := &broadcastRunner{chatID: 100, messageID: 5} + swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error { + if chatID == 1 { + runner.cancel.Store(true) + } + return nil + }, func(time.Duration) {}) + + (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5}) + + assertBroadcastResult(t, runner.getResult(), broadcastResult{ + Total: 5, + Delivered: 1, + Failed: 0, + Skipped: 4, + Canceled: true, + }) + if broadcastCurrentRunner() != nil { + t.Errorf("broadcast slot still registered after the run finished") + } +} + +// Regression: a 403 left the progress counter where it was, so a streak of +// unreachable chats at a multiple of broadcastProgressEvery edited the card per chat. +func TestRunBroadcastUnreachableKeepsProgressThrottled(t *testing.T) { + broadcastLocalizer(t) + url, calls, _ := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot can't initiate conversation with a user"} + swapBroadcastSender(t, func(int64, broadcastDraft) error { return blocked }, func(time.Duration) {}) + + runner := &broadcastRunner{chatID: 100, messageID: 5} + (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5}) + + // Five recipients sit under both throttle thresholds: only the summary edits the card. + if got := calls("editMessageText"); got != 1 { + t.Errorf("editMessageText calls = %d, want 1 (the summary alone)", got) + } +} + +// A long retry_after must not park the runner slot: the wait is slept in +// slices and an abort between them ends the recipient immediately. +func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) { + flood := &telegoapi.Error{ + ErrorCode: 429, + Description: "Too Many Requests: retry after 30", + Parameters: &telegoapi.ResponseParameters{RetryAfter: 30}, + } + + tests := []struct { + name string + abortAfter int // abort checks answered false before aborting; -1 never aborts + wantPauses int + wantErr string + }{ + {"a 30 s wait becomes six 5 s slices", -1, 30, `429 "Too Many Requests: retry after 30", migrate to chat ID: 0, retry after: 30`}, + {"an abort between slices ends the wait", 1, 1, errBroadcastAborted.Error()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mu sync.Mutex + var pauses []time.Duration + swapBroadcastSender(t, func(int64, broadcastDraft) error { return flood }, func(d time.Duration) { + mu.Lock() + defer mu.Unlock() + pauses = append(pauses, d) + }) + checks := 0 + aborted := func() bool { + if tt.abortAfter < 0 { + return false + } + checks++ + return checks > tt.abortAfter + } + + err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted) + + mu.Lock() + defer mu.Unlock() + if err == nil || err.Error() != tt.wantErr { + t.Errorf("broadcastDeliverOne() error = %v, want %q", err, tt.wantErr) + } + if len(pauses) != tt.wantPauses { + t.Fatalf("pauses = %d slices, want %d", len(pauses), tt.wantPauses) + } + for i, p := range pauses { + if p != broadcastFloodWaitSlice { + t.Errorf("pauses[%d] = %v, want %v", i, p, broadcastFloodWaitSlice) + } + } + }) + } +} + +// Regression: broadcast state used to survive a stop, leaving an armed album +// timer, a confirmable token and a held runner slot behind. +func TestStopBotResetsBroadcastState(t *testing.T) { + broadcastLocalizer(t) + url, calls, _ := newBroadcastMock(t) + swapTestBot(t, url) + swapAlbumDebounce(t, 20*time.Millisecond) + setBroadcastAdmins(t, []int64{5000}) + + const chatID = int64(9116) + resetBroadcastState(t) + origRunning := isRunning + t.Cleanup(func() { + tgBotMutex.Lock() + isRunning = origRunning + tgBotMutex.Unlock() + }) + + composeBroadcast(&Tgbot{}, telego.Message{ + Chat: telego.Chat{ID: chatID}, + From: &telego.User{ID: 5000}, + MessageID: 1, + MediaGroupID: "grpR", + }) + runner := broadcastRegisterRunner(chatID) + if runner == nil { + t.Fatal("broadcastRegisterRunner() = nil before the stop") + } + + StopBot() + + if broadcastCurrentRunner() != nil { + t.Errorf("broadcast slot survived StopBot") + } + if !runner.cancel.Load() { + t.Errorf("the active run was not cancelled on stop") + } + if _, _, ok := broadcastPendingDraft(chatUser{chatID: chatID, userID: 5000}); ok { + t.Errorf("a composition survived StopBot") + } + time.Sleep(60 * time.Millisecond) + if got := calls("copyMessage") + calls("sendMessage"); got != 0 { + t.Errorf("an armed album timer fired after StopBot (%d calls)", got) + } +} + +// The per-recipient pause scales with the copied batch size so an album does +// not multiply the messages per second on the wire. +func TestRunBroadcastAlbumPacing(t *testing.T) { + url, _, _ := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + + var pauses []time.Duration + swapBroadcastSender(t, func(int64, broadcastDraft) error { return nil }, func(d time.Duration) { + pauses = append(pauses, d) + }) + + runner := &broadcastRunner{chatID: 100, messageID: 5} + (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{1, 2, 3}}, []int64{1, 2}) + + if len(pauses) != 1 || pauses[0] != 3*broadcastSendDelay { + t.Errorf("pauses = %v, want one pause of %v for a three-message album", pauses, 3*broadcastSendDelay) + } +} + +func TestBroadcastDeliverOneRetries429(t *testing.T) { + flood := func(after int) error { + return &telegoapi.Error{ + ErrorCode: 429, + Description: "Too Many Requests: retry after " + fmt.Sprint(after), + Parameters: &telegoapi.ResponseParameters{RetryAfter: after}, + } + } + + tests := []struct { + name string + responses []error + canceled bool + wantErr string + wantCalls int + wantPauses []time.Duration + }{ + { + name: "flood control waits and retries the same recipient", + responses: []error{flood(2), nil}, + wantCalls: 2, + wantPauses: []time.Duration{2 * time.Second}, + }, + { + name: "gives up after the retry budget", + responses: []error{flood(1), flood(1), flood(1), flood(1), flood(1), flood(1), nil}, + wantErr: `429 "Too Many Requests: retry after 1", migrate to chat ID: 0, retry after: 1`, + wantCalls: 6, + wantPauses: []time.Duration{time.Second, time.Second, time.Second, time.Second, time.Second}, + }, + { + name: "non-429 errors are returned without retrying", + responses: []error{errors.New("connection reset")}, + wantErr: "connection reset", + wantCalls: 1, + }, + { + name: "a cancel during a flood wait abandons the recipient", + responses: []error{flood(2), nil}, + canceled: true, + wantErr: errBroadcastAborted.Error(), + wantCalls: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mu sync.Mutex + callNum := 0 + var pauses []time.Duration + swapBroadcastSender(t, func(int64, broadcastDraft) error { + mu.Lock() + defer mu.Unlock() + callNum++ + if callNum > len(tt.responses) { + return nil + } + return tt.responses[callNum-1] + }, func(d time.Duration) { + mu.Lock() + defer mu.Unlock() + pauses = append(pauses, d) + }) + canceled := func() bool { return tt.canceled } + + err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled) + + mu.Lock() + defer mu.Unlock() + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Errorf("broadcastDeliverOne() error = %v, want %q", err, tt.wantErr) + } + } else if err != nil { + t.Errorf("broadcastDeliverOne() error = %v, want nil", err) + } + if callNum != tt.wantCalls { + t.Errorf("sender calls = %d, want %d", callNum, tt.wantCalls) + } + if len(pauses) != len(tt.wantPauses) { + t.Fatalf("pauses = %v, want %v", pauses, tt.wantPauses) + } + for i, p := range tt.wantPauses { + if pauses[i] != p { + t.Errorf("pauses[%d] = %v, want %v", i, pauses[i], p) + } + } + }) + } +} + +func TestBroadcastRegisterRunnerSingleSlot(t *testing.T) { + first := broadcastRegisterRunner(1) + if first == nil { + t.Fatal("broadcastRegisterRunner() = nil for an idle bot") + } + t.Cleanup(func() { broadcastUnregisterRunner(first) }) + + if second := broadcastRegisterRunner(2); second != nil { + t.Fatalf("broadcastRegisterRunner() = %v while a broadcast is running, want nil", second) + } + + broadcastUnregisterRunner(first) + if broadcastCurrentRunner() != nil { + t.Fatalf("slot still registered after unregister") + } +} + +func TestStartBroadcastRefusesWhileRunning(t *testing.T) { + broadcastLocalizer(t) + const chatID = int64(9102) + resetBroadcastState(t) + + runner := broadcastRegisterRunner(chatID) + t.Cleanup(func() { + broadcastUnregisterRunner(runner) + }) + admin := chatUser{chatID: chatID, userID: chatID} + + (&Tgbot{}).startBroadcast(admin) + + if state, ok := userStateMgr.get(admin); ok { + t.Fatalf("state = %q while a broadcast is running, want none", state) + } +} + +func TestBroadcastCommandRequiresAdmin(t *testing.T) { + const chatID = int64(9101) + resetBroadcastState(t) + + message := &telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: chatID}, Text: "/broadcast"} + (&Tgbot{}).answerCommand(message, chatID, false) + + if state, ok := userStateMgr.get(messageActor(*message)); ok { + t.Fatalf("non-admin /broadcast set state %q", state) + } + if _, _, ok := broadcastPendingDraft(messageActor(*message)); ok { + t.Fatalf("non-admin /broadcast produced a draft") + } + if broadcastCurrentRunner() != nil { + t.Fatalf("non-admin /broadcast started a runner") + } +} + +func TestBroadcastStartCommandSetsState(t *testing.T) { + broadcastLocalizer(t) + const chatID = int64(9103) + resetBroadcastState(t) + defer func() { + userStateMgr.reset() + broadcastResetAll() + }() + + (&Tgbot{}).answerCommand(&telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: chatID}, Text: "/broadcast"}, chatID, true) + + state, ok := userStateMgr.get(chatUser{chatID: chatID, userID: chatID}) + if !ok || state != broadcastAwaitingText { + t.Fatalf("state = %q (ok=%v), want %q", state, ok, broadcastAwaitingText) + } +} + +func TestDeliverBroadcastCopy(t *testing.T) { + tests := []struct { + name string + draft broadcastDraft + wantMethods map[string]int + check func(t *testing.T, bodies func(string) []map[string]any) + }{ + { + name: "a single message rides copyMessage", + draft: broadcastDraft{FromChatID: 55, MessageIDs: []int{7}}, + wantMethods: map[string]int{"copyMessage": 1}, + check: func(t *testing.T, bodies func(string) []map[string]any) { + body := bodies("copyMessage")[0] + if fmt.Sprint(body["from_chat_id"]) != "55" || fmt.Sprint(body["message_id"]) != "7" { + t.Errorf("copy body = %v, want from 55 message 7", body) + } + }, + }, + { + name: "an album rides one copyMessages call", + draft: broadcastDraft{FromChatID: 55, MessageIDs: []int{1, 2, 3}}, + wantMethods: map[string]int{"copyMessages": 1, "copyMessage": 0}, + check: func(t *testing.T, bodies func(string) []map[string]any) { + if fmt.Sprint(bodies("copyMessages")[0]["message_ids"]) != "[1 2 3]" { + t.Errorf("message_ids = %v, want [1 2 3]", bodies("copyMessages")[0]["message_ids"]) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // A fresh mock per case keeps the per-method counts independent. + url, calls, bodies := newBroadcastMock(t) + swapTestBot(t, url) + + if err := deliverBroadcastCopy(66, tt.draft); err != nil { + t.Fatalf("deliverBroadcastCopy() error = %v", err) + } + for method, want := range tt.wantMethods { + if got := calls(method); got != want { + t.Errorf("%s calls = %d, want %d", method, got, want) + } + } + if tt.check != nil { + tt.check(t, bodies) + } + }) + } +} + +// Regression: a media group used to produce one draft per photo, so three +// photos meant three previews and only the last tapped one was delivered. +func TestHandleBroadcastInputMediaGroup(t *testing.T) { + broadcastLocalizer(t) + url, calls, bodies := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + swapAlbumDebounce(t, 20*time.Millisecond) + tb := newStaleButtonTgbot(t) + setBroadcastAdmins(t, []int64{5000}) + + const chatID = int64(9109) + resetBroadcastState(t) + createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 601)) + + // Updates of one album arrive out of order and copyMessages demands + // strictly increasing ids, so the draft must sort them. + for _, id := range []int{103, 101, 102} { + composeBroadcast(tb, telego.Message{ + Chat: telego.Chat{ID: chatID}, + From: &telego.User{ID: 5000}, + MessageID: id, + MediaGroupID: "grp9", + Photo: []telego.PhotoSize{{FileID: "unused"}}, + }) + } + + ids, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000}) + if !slices.Equal(ids, []int{101, 102, 103}) { + t.Fatalf("album draft ids = %v, want [101 102 103]", ids) + } + if token == "" { + t.Fatalf("album draft has no confirmation token") + } + if state, ok := userStateMgr.get(chatUser{chatID: chatID, userID: 5000}); ok { + t.Errorf("state = %q after the album was accepted, want cleared", state) + } + if got := calls("copyMessages"); got != 1 { + t.Errorf("copyMessages calls = %d, want 1 self-copy of the whole album", got) + } + // copyMessages rejects ids that are not strictly increasing. + if got := fmt.Sprint(bodies("copyMessages")[0]["message_ids"]); got != "[101 102 103]" { + t.Errorf("self-copy message_ids = %v, want [101 102 103]", got) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && calls("sendMessage") == 0 { + time.Sleep(2 * time.Millisecond) + } + if calls("sendMessage") != 1 { + t.Errorf("sendMessage calls = %d, want 1 confirmation card", calls("sendMessage")) + } +} + +func TestHandleBroadcastInputSingleMessage(t *testing.T) { + broadcastLocalizer(t) + url, calls, bodies := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + tb := newStaleButtonTgbot(t) + setBroadcastAdmins(t, []int64{5000}) + + const chatID = int64(9104) + resetBroadcastState(t) + createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 602)) + + composeBroadcast(tb, telego.Message{ + Chat: telego.Chat{ID: chatID}, + From: &telego.User{ID: 5000}, + MessageID: 42, + Text: "hello all", + }) + + ids, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000}) + if !slices.Equal(ids, []int{42}) { + t.Fatalf("draft ids = %v, want a reference to message 42", ids) + } + if got := calls("copyMessage"); got != 1 { + t.Errorf("copyMessage calls = %d, want 1 self-copy preview", got) + } + card := bodies("sendMessage")[0] + confirmData := card["reply_markup"].(map[string]any)["inline_keyboard"].([]any)[0].([]any)[0].(map[string]any)["callback_data"] + if confirmData != "broadcast_confirm "+token { + t.Errorf("card button = %v, want a confirm bound to the pending token %q", confirmData, token) + } +} + +// Regression: tapping Send on a superseded preview card delivered whatever +// draft happened to be pending instead of the card's own composition. +func TestBroadcastConfirmStaleTokenRejected(t *testing.T) { + broadcastLocalizer(t) + url, calls, _ := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + tb := newStaleButtonTgbot(t) + setBroadcastAdmins(t, []int64{5000}) + + const chatID = int64(9112) + resetBroadcastState(t) + createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 603)) + + composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 11, Text: "first"}) + _, staleToken := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000}) + + // A second composition replaces the first, so the first card goes stale. + composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 12, Text: "second"}) + ids, liveToken := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000}) + if !slices.Equal(ids, []int{12}) { + t.Fatalf("draft ids = %v, want only the second message", ids) + } + previewCopies := calls("copyMessage") + + tb.answerCallback(&telego.CallbackQuery{ + ID: "stale", + From: telego.User{ID: 5000}, + Data: "broadcast_confirm " + staleToken, + Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5}, + }, true) + if calls("copyMessage") != previewCopies { + t.Fatalf("a stale token started deliveries") + } + if broadcastCurrentRunner() != nil { + t.Fatalf("a stale token started a runner") + } + + tb.answerCallback(&telego.CallbackQuery{ + ID: "live", + From: telego.User{ID: 5000}, + Data: "broadcast_confirm " + liveToken, + Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 6}, + }, true) + waitBroadcastFinished(t) + if calls("copyMessage") != previewCopies+1 { + t.Errorf("copyMessage calls = %d, want %d (the live draft delivered once)", calls("copyMessage"), previewCopies+1) + } +} + +// Regression: composition stayed keyed by chat after the state moved to the +// admin, so a second admin's draft in a group dropped the first admin's. +func TestBroadcastComposesArePerAdmin(t *testing.T) { + broadcastLocalizer(t) + url, _, bodies := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + tb := newStaleButtonTgbot(t) + const groupChat, adminA, adminB = int64(-1009113), int64(5000), int64(5001) + setBroadcastAdmins(t, []int64{adminA, adminB}) + resetBroadcastState(t) + createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 604)) + + composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: groupChat}, From: &telego.User{ID: adminA}, MessageID: 21, Text: "from A"}) + composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: groupChat}, From: &telego.User{ID: adminB}, MessageID: 22, Text: "from B"}) + cards := bodies("sendMessage") + if len(cards) != 2 { + t.Fatalf("sendMessage calls = %d, want one preview card per admin", len(cards)) + } + + // Each admin confirms their own card, A first; each run must deliver its own draft. + for i, admin := range []int64{adminA, adminB} { + confirm := cards[i]["reply_markup"].(map[string]any)["inline_keyboard"].([]any)[0].([]any)[0].(map[string]any)["callback_data"].(string) + tb.answerCallback(&telego.CallbackQuery{ + ID: "q", + From: telego.User{ID: admin}, + Data: confirm, + Message: &telego.Message{Chat: telego.Chat{ID: groupChat}, MessageID: 30 + i}, + }, true) + waitBroadcastFinished(t) + } + + var delivered []string + for _, body := range bodies("copyMessage") { + if fmt.Sprint(body["chat_id"]) == "604" { + delivered = append(delivered, fmt.Sprint(body["message_id"])) + } + } + if !slices.Equal(delivered, []string{"21", "22"}) { + t.Errorf("messages delivered to the client = %v, want [21 22]: each admin's own draft", delivered) + } +} + +func waitBroadcastFinished(t *testing.T) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if broadcastCurrentRunner() == nil { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("broadcast did not finish in time") +} + +func TestConfirmBroadcastEndToEnd(t *testing.T) { + broadcastLocalizer(t) + url, calls, _ := newBroadcastMock(t) + swapTestBot(t, url) + setBroadcastRunning(t, true) + tb := newStaleButtonTgbot(t) + setBroadcastAdmins(t, []int64{5000}) + + const chatID = int64(9105) + resetBroadcastState(t) + + createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 501, 502)) + composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 9, Text: "hi"}) + _, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000}) + + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: 5000}, + Data: "broadcast_confirm " + token, + Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5}, + }, true) + + waitBroadcastFinished(t) + + // One preview self-copy plus two deliveries; the card is edited into the + // progress card and then into the summary, so nothing is sent twice. + if got := calls("copyMessage"); got != 3 { + t.Errorf("copyMessage calls = %d, want 3 (preview + 2 deliveries)", got) + } + if got := calls("sendMessage"); got != 1 { + t.Errorf("sendMessage calls = %d, want 1 confirmation card", got) + } + if got := calls("editMessageText"); got != 2 { + t.Errorf("editMessageText calls = %d, want 2 (progress + summary)", got) + } + if got := calls("answerCallbackQuery"); got != 1 { + t.Errorf("answerCallbackQuery calls = %d, want 1", got) + } +} + +func TestConfirmBroadcastWithoutDraftAnswersError(t *testing.T) { + url, calls, _ := newBroadcastMock(t) + swapTestBot(t, url) + tb := newStaleButtonTgbot(t) + const chatID = int64(9106) + resetBroadcastState(t) + + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: chatID}, + Data: "broadcast_confirm sometoken", + Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5}, + }, true) + + if calls("answerCallbackQuery") != 1 { + t.Errorf("answerCallbackQuery calls = %d, want 1 error answer", calls("answerCallbackQuery")) + } + if broadcastCurrentRunner() != nil { + t.Errorf("a confirm without a draft must not start a broadcast") + } +} + +func TestBroadcastCancelCallbackClearsDraft(t *testing.T) { + url, calls, _ := newBroadcastMock(t) + swapTestBot(t, url) + tb := newStaleButtonTgbot(t) + + const chatID = int64(9107) + resetBroadcastState(t) + + admin := chatUser{chatID: chatID, userID: chatID} + userStateMgr.set(admin, broadcastAwaitingText) + broadcastComposes[admin] = &broadcastCompose{messageIDs: []int{9}, token: "tok9"} + + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: chatID}, + Data: "broadcast_cancel", + Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 9}, + }, true) + + if _, ok := userStateMgr.get(admin); ok { + t.Errorf("state survived the cancel tap") + } + if _, _, ok := broadcastPendingDraft(admin); ok { + t.Errorf("draft survived the cancel tap") + } + if got := calls("deleteMessage"); got != 1 { + t.Errorf("deleteMessage calls = %d, want 1", got) + } + if got := calls("answerCallbackQuery"); got != 1 { + t.Errorf("answerCallbackQuery calls = %d, want 1", got) + } +} + +func TestBroadcastCallbacksDeniedToNonAdmin(t *testing.T) { + url, calls, _ := newBroadcastMock(t) + swapTestBot(t, url) + tb := newStaleButtonTgbot(t) + + const chatID = int64(9108) + resetBroadcastState(t) + + for _, data := range []string{"broadcast_confirm sometoken", "broadcast_cancel"} { + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: 999999}, + Data: data, + Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5}, + }, false) + if calls("answerCallbackQuery") != 0 { + t.Fatalf("%s answered a non-admin callback", data) + } + if broadcastCurrentRunner() != nil { + t.Fatalf("%s started a broadcast for a non-admin", data) + } + } +} diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index ebccaf9dd..285119665 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -115,6 +115,10 @@ func (t *Tgbot) OnReceive() { userStateMgr.maybePrune(time.Hour) actor := messageActor(message) if userState, exists := userStateMgr.get(actor); exists { + if userState == broadcastAwaitingText { + t.handleBroadcastInput(&message, actor) + return nil + } // Only a wizard step touches the draft, so only it takes the lock. draft := addClientDrafts.forActor(actor) draft.Lock() @@ -288,6 +292,13 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo } else { handleUnknownCommand() } + case "broadcast": + onlyMessage = true + if isAdmin { + t.startBroadcast(messageActor(*message)) + } else { + handleUnknownCommand() + } default: handleUnknownCommand() } @@ -342,6 +353,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool if len(dataArray) >= 2 && len(dataArray[1]) > 0 { email := dataArray[1] switch dataArray[0] { + case "broadcast_confirm": + t.confirmBroadcast(actor, dataArray[1], callbackQuery.Message.GetMessageID(), callbackQuery.ID) case "get_clients_for_sub": inboundIdInt, err := strconv.Atoi(dataArray[1]) if err != nil { @@ -901,6 +914,9 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool return } else { switch callbackQuery.Data { + case "broadcast_cancel": + t.cancelBroadcast(actor, callbackQuery.Message.GetMessageID(), callbackQuery.ID) + return case "get_inbounds": inbounds, err := t.getInbounds() if err != nil { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index cf9348353..a757fd1aa 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2425,6 +2425,7 @@ "usageDesc": "عرض استهلاك العميل: /usage البريد", "inboundDesc": "البحث في الواردات: /inbound الاسم (مشرف)", "restartDesc": "إعادة تشغيل نواة Xray (مشرف)", + "broadcastDesc": "إرسال رسالة لجميع العملاء (مسؤول)", "clearallDesc": "تصفير استهلاك جميع العملاء (مشرف)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ خطأ:\n\n {{ .error }}", "using_default_value": "تمام، هشيل على القيمة الافتراضية. 😊", "incorrect_input": "المدخلات مش صحيحة.\nالكلمات لازم تكون متصلة من غير فراغات.\nمثال صحيح: aaaaaa\nمثال غلط: aaa aaa 🚫", + "broadcastAskText": "📤 بث جماعي: أرسل لي الرسالة لتوصيلها إلى عملائك — نص أو صورة أو فيديو أو ملفًا أو ألبومًا كاملًا.", + "broadcastAlreadyRunning": "❗ هناك بث جارٍ بالفعل، انتظر حتى ينتهي.", + "broadcastNoRecipients": "❗ لا يوجد عملاء بمعرّف تلجرام مرتبط.", + "broadcastPreview": "📤 ستُرسل هذه الرسالة إلى {{ .Count }} مستلمًا. أرسل؟", + "broadcastNotCopyable": "❗ لا يمكن نسخ هذه الرسالة للبث. أرسل رسالة أخرى.", + "broadcastUnreachable": "ℹ️ {{ .Count }} مستلمًا لم يبدؤوا محادثة مع البوت (أو حجبوه) ولا يمكن مراسلتهم — اطلب منهم الضغط على Start.", + "broadcastProgress": "📤 تم إرسال {{ .Sent }} من {{ .Total }} (فاشلة: {{ .Failed }})\r\n", + "broadcastFinished": "✅ انتهى البث.\r\n👥 المستلمون: {{ .Total }}\r\n📨 تم توصيلها: {{ .Sent }}\r\n🚫 فاشلة: {{ .Failed }}\r\n⏭ تم تخطيها: {{ .Skipped }}\r\n⏱ الوقت: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 تم إلغاء البث.\r\n👥 المستلمون: {{ .Total }}\r\n📨 تم توصيلها: {{ .Sent }}\r\n🚫 فاشلة: {{ .Failed }}\r\n⏭ تم تخطيها: {{ .Skipped }}\r\n⏱ الوقت: {{ .Time }}\r\n", "AreYouSure": "إنت متأكد؟ 🤔", "SuccessResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ✅ تم بنجاح", "FailedResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ❌ فشل \n\n🛠️ الخطأ: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ اقفل الكيبورد", "cancel": "❌ إلغاء", + "broadcastSend": "📤 إرسال", "cancelReset": "❌ إلغاء إعادة الضبط", "cancelIpLimit": "❌ إلغاء حد الـ IP", "confirmResetTraffic": "✅ تأكيد إعادة ضبط الترافيك؟", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب" }, "answers": { + "broadcastStarted": "🚀 بدأ البث.", + "broadcastCanceling": "🛑 سيتوقف البث بعد المستلم الحالي.", + "broadcastCanceled": "❌ تم إلغاء البث.", "successfulOperation": "✅ العملية نجحت!", "errorOperation": "❗ حصل خطأ في العملية.", "getInboundsFailed": "❌ فشل الحصول على الإدخالات.", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 94638318b..e8aa3ae34 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -2425,6 +2425,7 @@ "usageDesc": "Show client usage: /usage email", "inboundDesc": "Search inbounds: /inbound remark (admin)", "restartDesc": "Restart Xray core (admin)", + "broadcastDesc": "Broadcast a message to all clients (admin)", "clearallDesc": "Reset all clients' traffic (admin)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Error:\n\n {{ .error }}", "using_default_value": "Okay, I'll stick with the default value. 😊", "incorrect_input": "Your input is not valid.\nThe phrases should be continuous without spaces.\nCorrect example: aaaaaa\nIncorrect example: aaa aaa 🚫", + "broadcastAskText": "📤 Broadcast: send me the message to deliver to your clients — text, photo, video, file or a whole album.", + "broadcastAlreadyRunning": "❗ A broadcast is already running, please wait for it to finish.", + "broadcastNoRecipients": "❗ No clients with a linked Telegram ID to deliver to.", + "broadcastPreview": "📤 This message will go to {{ .Count }} recipients. Send it?", + "broadcastNotCopyable": "❗ This message can't be copied for broadcast. Send another one.", + "broadcastUnreachable": "ℹ️ {{ .Count }} recipients never started the bot (or blocked it) and cannot be messaged — ask them to press Start.", + "broadcastProgress": "📤 Sent {{ .Sent }} of {{ .Total }} (failed: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Broadcast finished.\r\n👥 Recipients: {{ .Total }}\r\n📨 Delivered: {{ .Sent }}\r\n🚫 Failed: {{ .Failed }}\r\n⏭ Skipped: {{ .Skipped }}\r\n⏱ Time: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Broadcast canceled.\r\n👥 Recipients: {{ .Total }}\r\n📨 Delivered: {{ .Sent }}\r\n🚫 Failed: {{ .Failed }}\r\n⏭ Skipped: {{ .Skipped }}\r\n⏱ Time: {{ .Time }}\r\n", "AreYouSure": "Are you sure? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ✅ Success", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ❌ Failed \n\n🛠️ Error: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Close Keyboard", "cancel": "❌ Cancel", + "broadcastSend": "📤 Send now", "cancelReset": "❌ Cancel Reset", "cancelIpLimit": "❌ Cancel IP Limit", "confirmResetTraffic": "✅ Confirm Reset Traffic?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Sorted Traffic Usage Report" }, "answers": { + "broadcastStarted": "🚀 Broadcast started.", + "broadcastCanceling": "🛑 The broadcast will stop after the current recipient.", + "broadcastCanceled": "❌ Broadcast canceled.", "successfulOperation": "✅ Operation successful!", "errorOperation": "❗ Error in operation.", "getInboundsFailed": "❌ Failed to get inbounds.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index b9721e53a..e4a7d99f1 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -2425,6 +2425,7 @@ "usageDesc": "Ver el uso del cliente: /usage correo", "inboundDesc": "Buscar entradas: /inbound nombre (admin)", "restartDesc": "Reiniciar el núcleo de Xray (admin)", + "broadcastDesc": "Enviar un mensaje a todos los clientes (admin)", "clearallDesc": "Restablecer el tráfico de todos los clientes (admin)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Error:\n\n {{ .error }}", "using_default_value": "Está bien, me quedaré con el valor predeterminado. 😊", "incorrect_input": "Tu entrada no es válida.\nLas frases deben ser continuas sin espacios.\nEjemplo correcto: aaaaaa\nEjemplo incorrecto: aaa aaa 🚫", + "broadcastAskText": "📤 Difusión: envíame el mensaje para entregarlo a tus clientes — texto, foto, vídeo, archivo o un álbum completo.", + "broadcastAlreadyRunning": "❗ Ya hay una difusión en curso, espera a que termine.", + "broadcastNoRecipients": "❗ No hay clientes con un ID de Telegram vinculado.", + "broadcastPreview": "📤 Este mensaje se enviará a {{ .Count }} destinatarios. ¿Enviar?", + "broadcastNotCopyable": "❗ Este mensaje no se puede copiar para la difusión. Envía otro.", + "broadcastUnreachable": "ℹ️ {{ .Count }} destinatarios nunca iniciaron el bot (o lo bloquearon) y no se les puede escribir; pídeles que pulsen Start.", + "broadcastProgress": "📤 Enviados {{ .Sent }} de {{ .Total }} (fallidos: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Difusión terminada.\r\n👥 Destinatarios: {{ .Total }}\r\n📨 Entregados: {{ .Sent }}\r\n🚫 Fallidos: {{ .Failed }}\r\n⏭ Omitidos: {{ .Skipped }}\r\n⏱ Tiempo: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Difusión cancelada.\r\n👥 Destinatarios: {{ .Total }}\r\n📨 Entregados: {{ .Sent }}\r\n🚫 Fallidos: {{ .Failed }}\r\n⏭ Omitidos: {{ .Skipped }}\r\n⏱ Tiempo: {{ .Time }}\r\n", "AreYouSure": "¿Estás seguro? 🤔", "SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito", "FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Cerrar Teclado", "cancel": "❌ Cancelar", + "broadcastSend": "📤 Enviar", "cancelReset": "❌ Cancelar Reinicio", "cancelIpLimit": "❌ Cancelar Límite de IP", "confirmResetTraffic": "✅ ¿Confirmar Reinicio de Tráfico?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Informe de uso de tráfico ordenado" }, "answers": { + "broadcastStarted": "🚀 Difusión iniciada.", + "broadcastCanceling": "🛑 La difusión se detendrá tras el destinatario actual.", + "broadcastCanceled": "❌ Difusión cancelada.", "successfulOperation": "✅ ¡Exitosa!", "errorOperation": "❗ Error en la Operación.", "getInboundsFailed": "❌ Error al obtener las entradas", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index cca66e175..7a7661e50 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2425,6 +2425,7 @@ "usageDesc": "مشاهده مصرف کاربر: /usage ایمیل", "inboundDesc": "جستجوی ورودی‌ها: /inbound نام (مدیر)", "restartDesc": "راه‌اندازی مجدد هسته Xray (مدیر)", + "broadcastDesc": "ارسال پیام به همه کاربران (مدیر)", "clearallDesc": "صفر کردن ترافیک همه کاربران (مدیر)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ خطا:\n\n {{ .error }}", "using_default_value": "باشه، از مقدار پیش‌فرض استفاده می‌کنم. 😊", "incorrect_input": "ورودی شما معتبر نیست.\nعبارت‌ها باید بدون فاصله باشند.\nمثال صحیح: aaaaaa\nمثال نادرست: aaa aaa 🚫", + "broadcastAskText": "📤 ارسال گروهی: پیام خود را برای ارسال به کاربران بفرستید — متن، عکس، ویدیو، فایل یا یک آلبوم کامل.", + "broadcastAlreadyRunning": "❗ یک ارسال گروهی در حال اجراست، تا پایان آن صبر کنید.", + "broadcastNoRecipients": "❗ هیچ کاربری با شناسه تلگرام متصل یافت نشد.", + "broadcastPreview": "📤 این پیام برای {{ .Count }} دریافت‌کننده ارسال می‌شود. ارسال شود؟", + "broadcastNotCopyable": "❗ این پیام برای ارسال گروهی قابل کپی نیست. پیام دیگری بفرستید.", + "broadcastUnreachable": "ℹ️ {{ .Count }} دریافت‌کننده هرگز با ربات گفتگو را شروع نکرده‌اند (یا آن را بلاک کرده‌اند) و به آن‌ها پیام فرستاده نمی‌شود — از آن‌ها بخواهید Start را بزنند.", + "broadcastProgress": "📤 ارسال‌شده {{ .Sent }} از {{ .Total }} (ناموفق: {{ .Failed }})\r\n", + "broadcastFinished": "✅ ارسال گروهی پایان یافت.\r\n👥 دریافت‌کنندگان: {{ .Total }}\r\n📨 تحویل‌شده: {{ .Sent }}\r\n🚫 ناموفق: {{ .Failed }}\r\n⏭ ردشده: {{ .Skipped }}\r\n⏱ زمان: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 ارسال گروهی لغو شد.\r\n👥 دریافت‌کنندگان: {{ .Total }}\r\n📨 تحویل‌شده: {{ .Sent }}\r\n🚫 ناموفق: {{ .Failed }}\r\n⏭ ردشده: {{ .Skipped }}\r\n⏱ زمان: {{ .Time }}\r\n", "AreYouSure": "مطمئنی؟ 🤔", "SuccessResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیت‌آمیز", "FailedResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ بستن کیبورد", "cancel": "❌ لغو", + "broadcastSend": "📤 ارسال", "cancelReset": "❌ لغو تنظیم مجدد", "cancelIpLimit": "❌ لغو محدودیت آی‌پی", "confirmResetTraffic": "✅ تأیید تنظیم مجدد ترافیک؟", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده" }, "answers": { + "broadcastStarted": "🚀 ارسال گروهی آغاز شد.", + "broadcastCanceling": "🛑 ارسال گروهی پس از دریافت‌کننده فعلی متوقف می‌شود.", + "broadcastCanceled": "❌ ارسال گروهی لغو شد.", "successfulOperation": "✅ انجام شد!", "errorOperation": "❗ خطا در عملیات.", "getInboundsFailed": "❌ دریافت ورودی‌ها با خطا مواجه شد.", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 3b48d1b5d..93e7b5537 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -2425,6 +2425,7 @@ "usageDesc": "Lihat pemakaian klien: /usage email", "inboundDesc": "Cari inbound: /inbound nama (admin)", "restartDesc": "Mulai ulang inti Xray (admin)", + "broadcastDesc": "Kirim pesan ke semua klien (admin)", "clearallDesc": "Reset trafik semua klien (admin)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Error:\n\n {{ .error }}", "using_default_value": "Oke, saya akan tetap menggunakan nilai default. 😊", "incorrect_input": "Masukan Anda tidak valid.\nFrasa harus berlanjut tanpa spasi.\nContoh benar: aaaaaa\nContoh salah: aaa aaa 🚫", + "broadcastAskText": "📡 Siaran: kirimkan pesan untuk dikirimkan ke klien Anda — teks, foto, video, file, atau satu album lengkap.", + "broadcastAlreadyRunning": "❗ Siaran sedang berjalan, tunggu hingga selesai.", + "broadcastNoRecipients": "❗ Tidak ada klien dengan ID Telegram yang terhubung.", + "broadcastPreview": "📤 Pesan ini akan dikirim ke {{ .Count }} penerima. Kirim?", + "broadcastNotCopyable": "❗ Pesan ini tidak bisa disalin untuk siaran. Kirim pesan lain.", + "broadcastUnreachable": "ℹ️ {{ .Count }} penerima belum pernah memulai bot (atau memblokirnya) dan tidak bisa dikirimi pesan — minta mereka menekan Start.", + "broadcastProgress": "📤 Terkirim {{ .Sent }} dari {{ .Total }} (gagal: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Siaran selesai.\r\n👥 Penerima: {{ .Total }}\r\n📨 Terkirim: {{ .Sent }}\r\n🚫 Gagal: {{ .Failed }}\r\n⏭ Dilewati: {{ .Skipped }}\r\n⏱ Waktu: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Siaran dibatalkan.\r\n👥 Penerima: {{ .Total }}\r\n📨 Terkirim: {{ .Sent }}\r\n🚫 Gagal: {{ .Failed }}\r\n⏭ Dilewati: {{ .Skipped }}\r\n⏱ Waktu: {{ .Time }}\r\n", "AreYouSure": "Apakah kamu yakin? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Tutup Papan Ketik", "cancel": "❌ Batal", + "broadcastSend": "📤 Kirim", "cancelReset": "❌ Batal Reset", "cancelIpLimit": "❌ Batal Batas IP", "confirmResetTraffic": "✅ Konfirmasi Reset Lalu Lintas?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut" }, "answers": { + "broadcastStarted": "🚀 Siaran dimulai.", + "broadcastCanceling": "🛑 Siaran akan berhenti setelah penerima saat ini.", + "broadcastCanceled": "❌ Siaran dibatalkan.", "successfulOperation": "✅ Operasi berhasil!", "errorOperation": "❗ Kesalahan dalam operasi.", "getInboundsFailed": "❌ Gagal mendapatkan inbounds.", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 3746ff2de..51a80699b 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2425,6 +2425,7 @@ "usageDesc": "クライアント使用量を表示: /usage メール", "inboundDesc": "インバウンド検索: /inbound 備考(管理者)", "restartDesc": "Xray コアを再起動(管理者)", + "broadcastDesc": "全クライアントへ一括送信(管理者)", "clearallDesc": "全クライアントのトラフィックをリセット(管理者)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ エラー:\n\n {{ .error }}", "using_default_value": "わかりました、デフォルト値を使用します。 😊", "incorrect_input": "入力が無効です。\nフレーズはスペースなしで続けて入力してください。\n正しい例: aaaaaa\n間違った例: aaa aaa 🚫", + "broadcastAskText": "📤 一括送信:クライアントに配信するメッセージを送ってください — テキスト、写真、動画、ファイル、アルバム全体も OK。", + "broadcastAlreadyRunning": "❗ 一括送信はすでに実行中です。完了までお待ちください。", + "broadcastNoRecipients": "❗ Telegram ID が紐付いたクライアントがいません。", + "broadcastPreview": "📤 このメッセージは {{ .Count }} 人の受信者に送信されます。送信しますか?", + "broadcastNotCopyable": "❗ このメッセージは一括送信にコピーできません。別のメッセージを送ってください。", + "broadcastUnreachable": "ℹ️ {{ .Count }} 人の受信者はボットとの会話を開始していない(またはブロックしている)ため送信できません — Start を押すよう案内してください。", + "broadcastProgress": "📤 {{ .Total }} 人中 {{ .Sent }} 人に送信済み(失敗:{{ .Failed }})\r\n", + "broadcastFinished": "✅ 一括送信が完了しました。\r\n👥 受信者:{{ .Total }}\r\n📨 送信済み:{{ .Sent }}\r\n🚫 失敗:{{ .Failed }}\r\n⏭ スキップ:{{ .Skipped }}\r\n⏱ 時間:{{ .Time }}\r\n", + "broadcastCanceled": "🛑 一括送信をキャンセルしました。\r\n👥 受信者:{{ .Total }}\r\n📨 送信済み:{{ .Sent }}\r\n🚫 失敗:{{ .Failed }}\r\n⏭ スキップ:{{ .Skipped }}\r\n⏱ 時間:{{ .Time }}\r\n", "AreYouSure": "本当にいいですか?🤔", "SuccessResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功", "FailedResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ キーボードを閉じる", "cancel": "❌ キャンセル", + "broadcastSend": "📤 送信", "cancelReset": "❌ リセットをキャンセル", "cancelIpLimit": "❌ IP制限をキャンセル", "confirmResetTraffic": "✅ トラフィックをリセットしますか?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "ソートされたトラフィック使用レポート" }, "answers": { + "broadcastStarted": "🚀 一括送信を開始しました。", + "broadcastCanceling": "🛑 現在の受信者の後に一括送信を停止します。", + "broadcastCanceled": "❌ 一括送信をキャンセルしました。", "successfulOperation": "✅ 成功!", "errorOperation": "❗ 操作エラー。", "getInboundsFailed": "❌ インバウンド情報の取得に失敗しました。", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 9dd49eb04..4d1df5def 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -2425,6 +2425,7 @@ "usageDesc": "Ver o uso do cliente: /usage email", "inboundDesc": "Buscar entradas: /inbound nome (admin)", "restartDesc": "Reiniciar o núcleo Xray (admin)", + "broadcastDesc": "Enviar uma mensagem para todos os clientes (admin)", "clearallDesc": "Zerar o tráfego de todos os clientes (admin)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Erro:\n\n {{ .error }}", "using_default_value": "Tudo bem, vou manter o valor padrão. 😊", "incorrect_input": "Sua entrada não é válida.\nAs frases devem ser contínuas, sem espaços.\nExemplo correto: aaaaaa\nExemplo incorreto: aaa aaa 🚫", + "broadcastAskText": "📤 Transmissão: envie-me a mensagem para entregar aos seus clientes — texto, foto, vídeo, arquivo ou um álbum inteiro.", + "broadcastAlreadyRunning": "❗ Já existe uma transmissão em andamento, aguarde a conclusão.", + "broadcastNoRecipients": "❗ Nenhum cliente com ID do Telegram vinculado.", + "broadcastPreview": "📤 Esta mensagem irá para {{ .Count }} destinatários. Enviar?", + "broadcastNotCopyable": "❗ Não é possível copiar esta mensagem para a transmissão. Envie outra.", + "broadcastUnreachable": "ℹ️ {{ .Count }} destinatários nunca iniciaram o bot (ou o bloquearam) e não podem receber mensagens — peça que pressionem Start.", + "broadcastProgress": "📤 Enviados {{ .Sent }} de {{ .Total }} (falhas: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Transmissão concluída.\r\n👥 Destinatários: {{ .Total }}\r\n📨 Entregues: {{ .Sent }}\r\n🚫 Falhas: {{ .Failed }}\r\n⏭ Ignorados: {{ .Skipped }}\r\n⏱ Tempo: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Transmissão cancelada.\r\n👥 Destinatários: {{ .Total }}\r\n📨 Entregues: {{ .Sent }}\r\n🚫 Falhas: {{ .Failed }}\r\n⏭ Ignorados: {{ .Skipped }}\r\n⏱ Tempo: {{ .Time }}\r\n", "AreYouSure": "Você tem certeza? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Fechar teclado", "cancel": "❌ Cancelar", + "broadcastSend": "📤 Enviar", "cancelReset": "❌ Cancelar redefinição", "cancelIpLimit": "❌ Cancelar limite de IP", "confirmResetTraffic": "✅ Confirmar redefinição de tráfego?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado" }, "answers": { + "broadcastStarted": "🚀 Transmissão iniciada.", + "broadcastCanceling": "🛑 A transmissão parará após o destinatário atual.", + "broadcastCanceled": "❌ Transmissão cancelada.", "successfulOperation": "✅ Operação bem-sucedida!", "errorOperation": "❗ Erro na operação.", "getInboundsFailed": "❌ Falha ao obter inbounds.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index aa9cc281e..710e9580d 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2425,6 +2425,7 @@ "usageDesc": "Показать трафик клиента: /usage email", "inboundDesc": "Поиск входящих: /inbound имя (админ)", "restartDesc": "Перезапустить ядро Xray (админ)", + "broadcastDesc": "Массовая рассылка всем клиентам (админ)", "clearallDesc": "Сбросить трафик всех клиентов (админ)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Ошибка:\n\n {{ .error }}", "using_default_value": "Используется значение по умолчанию👌", "incorrect_input": "Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫", + "broadcastAskText": "📤 Рассылка: пришлите сообщение для ваших клиентов — текст, фото, видео, файл или целый альбом.", + "broadcastAlreadyRunning": "❗ Рассылка уже идёт, дождитесь её завершения.", + "broadcastNoRecipients": "❗ Нет клиентов с привязанным Telegram ID.", + "broadcastPreview": "📤 Это сообщение уйдёт {{ .Count }} получателям. Отправить?", + "broadcastNotCopyable": "❗ Это сообщение нельзя скопировать для рассылки. Пришлите другое.", + "broadcastUnreachable": "ℹ️ {{ .Count }} получателей ни разу не начинали диалог с ботом (или заблокировали его) — им нельзя написать. Попросите их нажать Start.", + "broadcastProgress": "📤 Отправлено {{ .Sent }} из {{ .Total }} (не доставлено: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Рассылка завершена.\r\n👥 Получателей: {{ .Total }}\r\n📨 Доставлено: {{ .Sent }}\r\n🚫 Не доставлено: {{ .Failed }}\r\n⏭ Пропущено: {{ .Skipped }}\r\n⏱ Время: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Рассылка отменена.\r\n👥 Получателей: {{ .Total }}\r\n📨 Доставлено: {{ .Sent }}\r\n🚫 Не доставлено: {{ .Failed }}\r\n⏭ Пропущено: {{ .Skipped }}\r\n⏱ Время: {{ .Time }}\r\n", "AreYouSure": "Вы уверены? 🤔", "SuccessResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно", "FailedResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Закрыть клавиатуру", "cancel": "❌ Отмена", + "broadcastSend": "📤 Отправить", "cancelReset": "❌ Отменить сброс", "cancelIpLimit": "❌ Отменить лимит IP", "confirmResetTraffic": "✅ Подтвердить сброс трафика?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика" }, "answers": { + "broadcastStarted": "🚀 Рассылка запущена.", + "broadcastCanceling": "🛑 Рассылка остановится после текущего получателя.", + "broadcastCanceled": "❌ Рассылка отменена.", "successfulOperation": "✅ Успешно!", "errorOperation": "❗ Ошибка в операции.", "getInboundsFailed": "❌ Не удалось получить входящие подключения.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index f8c900216..ac6a18299 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -2425,6 +2425,7 @@ "usageDesc": "İstemci kullanımını göster: /usage e-posta", "inboundDesc": "Gelenleri ara: /inbound ad (yönetici)", "restartDesc": "Xray çekirdeğini yeniden başlat (yönetici)", + "broadcastDesc": "Tüm istemcilere mesaj gönder (yönetici)", "clearallDesc": "Tüm istemcilerin trafiğini sıfırla (yönetici)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Hata:\n\n {{ .error }}", "using_default_value": "Tamam, varsayılan değeri kullanacağım. 😊", "incorrect_input": "Geçersiz değer girdiniz.\nİfadeler arasında boşluk olmamalıdır.\nDoğru örnek: aaaaaa\nYanlış örnek: aaa aaa 🚫", + "broadcastAskText": "📤 Toplu gönderim: istemcilerinize iletmek istediğiniz mesajı bana gönderin — metin, fotoğraf, video, dosya veya bir albüm.", + "broadcastAlreadyRunning": "❗ Zaten devam eden bir toplu gönderim var, bitmesini bekleyin.", + "broadcastNoRecipients": "❗ Bağlı bir Telegram ID'si olan istemci yok.", + "broadcastPreview": "📤 Bu mesaj {{ .Count }} alıcıya gönderilecek. Gönderilsin mi?", + "broadcastNotCopyable": "❗ Bu mesaj toplu gönderim için kopyalanamıyor. Başka bir mesaj gönderin.", + "broadcastUnreachable": "ℹ️ {{ .Count }} alıcı botu hiç başlatmamış (veya engellemiş) ve mesaj gönderilemiyor — Start'a basmalarını isteyin.", + "broadcastProgress": "📤 {{ .Total }} alıcıdan {{ .Sent }} gönderildi (başarısız: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Toplu gönderim tamamlandı.\r\n👥 Alıcılar: {{ .Total }}\r\n📨 İletilen: {{ .Sent }}\r\n🚫 Başarısız: {{ .Failed }}\r\n⏭ Atlanan: {{ .Skipped }}\r\n⏱ Süre: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Toplu gönderim iptal edildi.\r\n👥 Alıcılar: {{ .Total }}\r\n📨 İletilen: {{ .Sent }}\r\n🚫 Başarısız: {{ .Failed }}\r\n⏭ Atlanan: {{ .Skipped }}\r\n⏱ Süre: {{ .Time }}\r\n", "AreYouSure": "Emin misiniz? 🤔", "SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı", "FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Klavyeyi Kapat", "cancel": "❌ İptal", + "broadcastSend": "📤 Gönder", "cancelReset": "❌ Sıfırlamayı İptal Et", "cancelIpLimit": "❌ IP Limitini İptal Et", "confirmResetTraffic": "✅ Trafiği Sıfırlamayı Onayla?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu" }, "answers": { + "broadcastStarted": "🚀 Toplu gönderim başladı.", + "broadcastCanceling": "🛑 Gönderim mevcut alıcıdan sonra duracak.", + "broadcastCanceled": "❌ Toplu gönderim iptal edildi.", "successfulOperation": "✅ İşlem başarılı!", "errorOperation": "❗ İşlemde hata.", "getInboundsFailed": "❌ Gelen Bağlantılar alınamadı.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 0768baa1a..69b86f1ff 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2425,6 +2425,7 @@ "usageDesc": "Показати трафік клієнта: /usage email", "inboundDesc": "Пошук вхідних: /inbound назва (адмін)", "restartDesc": "Перезапустити ядро Xray (адмін)", + "broadcastDesc": "Масове розсилання всім клієнтам (адмін)", "clearallDesc": "Скинути трафік усіх клієнтів (адмін)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Помилка:\n\n {{ .error }}", "using_default_value": "Гаразд, залишу значення за замовчуванням. 😊", "incorrect_input": "Ваш ввід невірний.\nФрази повинні бути без пробілів.\nПравильний приклад: aaaaaa\nНеправильний приклад: aaa aaa 🚫", + "broadcastAskText": "📤 Розсилка: надішліть повідомлення для ваших клієнтів — текст, фото, відео, файл або цілий альбом.", + "broadcastAlreadyRunning": "❗ Розсилка вже триває, зачекайте її завершення.", + "broadcastNoRecipients": "❗ Немає клієнтів із прив'язаним Telegram ID.", + "broadcastPreview": "📤 Це повідомлення отримають {{ .Count }} отримувачів. Надіслати?", + "broadcastNotCopyable": "❗ Це повідомлення не можна скопіювати для розсилки. Надішліть інше.", + "broadcastUnreachable": "ℹ️ {{ .Count }} отримувачів жодного разу не починали діалог із ботом (або заблокували його) — їм не можна написати. Попросіть їх натиснути Start.", + "broadcastProgress": "📤 Надіслано {{ .Sent }} із {{ .Total }} (не доставлено: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Розсилку завершено.\r\n👥 Отримувачів: {{ .Total }}\r\n📨 Доставлено: {{ .Sent }}\r\n🚫 Не доставлено: {{ .Failed }}\r\n⏭ Пропущено: {{ .Skipped }}\r\n⏱ Час: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Розсилку скасовано.\r\n👥 Отримувачів: {{ .Total }}\r\n📨 Доставлено: {{ .Sent }}\r\n🚫 Не доставлено: {{ .Failed }}\r\n⏭ Пропущено: {{ .Skipped }}\r\n⏱ Час: {{ .Time }}\r\n", "AreYouSure": "Ви впевнені? 🤔", "SuccessResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно", "FailedResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Закрити клавіатуру", "cancel": "❌ Скасувати", + "broadcastSend": "📤 Надіслати", "cancelReset": "❌ Скасувати скидання", "cancelIpLimit": "❌ Скасувати обмеження IP", "confirmResetTraffic": "✅ Підтвердити скидання трафіку?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Відсортований звіт про використання трафіку" }, "answers": { + "broadcastStarted": "🚀 Розсилку запущено.", + "broadcastCanceling": "🛑 Розсилка зупиниться після поточного отримувача.", + "broadcastCanceled": "❌ Розсилку скасовано.", "successfulOperation": "✅ Операція успішна!", "errorOperation": "❗ Помилка в роботі.", "getInboundsFailed": "❌ Не вдалося отримати вхідні повідомлення.", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index e7af268cd..ce9a1cc9a 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -2425,6 +2425,7 @@ "usageDesc": "Xem mức dùng của khách: /usage email", "inboundDesc": "Tìm inbound: /inbound tên (quản trị)", "restartDesc": "Khởi động lại lõi Xray (quản trị)", + "broadcastDesc": "Gửi tin nhắn tới tất cả khách hàng (quản trị)", "clearallDesc": "Đặt lại lưu lượng mọi khách hàng (quản trị)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ Lỗi:\n\n {{ .error }}", "using_default_value": "Được rồi, tôi sẽ sử dụng giá trị mặc định. 😊", "incorrect_input": "Dữ liệu bạn nhập không hợp lệ.\nCác chuỗi phải liền mạch và không có dấu cách.\nVí dụ đúng: aaaaaa\nVí dụ sai: aaa aaa 🚫", + "broadcastAskText": "📤 Phát tin: gửi cho tôi tin nhắn để chuyển tới khách hàng của bạn — văn bản, ảnh, video, tệp hoặc cả bộ ảnh.", + "broadcastAlreadyRunning": "❗ Một bản phát đang chạy, hãy đợi nó hoàn tất.", + "broadcastNoRecipients": "❗ Không có khách hàng nào gắn Telegram ID.", + "broadcastPreview": "📤 Tin nhắn này sẽ được gửi tới {{ .Count }} người nhận. Gửi chứ?", + "broadcastNotCopyable": "❗ Không thể sao chép tin nhắn này để phát. Hãy gửi tin nhắn khác.", + "broadcastUnreachable": "ℹ️ {{ .Count }} người nhận chưa từng bắt đầu bot (hoặc đã chặn nó) nên không thể nhắn tin — hãy đề nghị họ nhấn Start.", + "broadcastProgress": "📤 Đã gửi {{ .Sent }}/{{ .Total }} (thất bại: {{ .Failed }})\r\n", + "broadcastFinished": "✅ Phát tin hoàn tất.\r\n👥 Người nhận: {{ .Total }}\r\n📨 Đã gửi: {{ .Sent }}\r\n🚫 Thất bại: {{ .Failed }}\r\n⏭ Bỏ qua: {{ .Skipped }}\r\n⏱ Thời gian: {{ .Time }}\r\n", + "broadcastCanceled": "🛑 Đã hủy phát tin.\r\n👥 Người nhận: {{ .Total }}\r\n📨 Đã gửi: {{ .Sent }}\r\n🚫 Thất bại: {{ .Failed }}\r\n⏭ Bỏ qua: {{ .Skipped }}\r\n⏱ Thời gian: {{ .Time }}\r\n", "AreYouSure": "Bạn có chắc không? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ Đóng Bàn Phím", "cancel": "❌ Hủy", + "broadcastSend": "📤 Gửi ngay", "cancelReset": "❌ Hủy Đặt Lại", "cancelIpLimit": "❌ Hủy Giới Hạn IP", "confirmResetTraffic": "✅ Xác Nhận Đặt Lại Lưu Lượng?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp" }, "answers": { + "broadcastStarted": "🚀 Đã bắt đầu phát tin.", + "broadcastCanceling": "🛑 Bản phát sẽ dừng sau người nhận hiện tại.", + "broadcastCanceled": "❌ Đã hủy phát tin.", "successfulOperation": "✅ Thành công!", "errorOperation": "❗ Lỗi Trong Quá Trình Thực Hiện.", "getInboundsFailed": "❌ Không Thể Lấy Được Inbounds", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index aee16fbd7..47524d01f 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2425,6 +2425,7 @@ "usageDesc": "查看客户端用量:/usage 邮箱", "inboundDesc": "搜索入站:/inbound 备注(管理员)", "restartDesc": "重启 Xray 内核(管理员)", + "broadcastDesc": "向所有客户端群发消息(管理员)", "clearallDesc": "重置所有客户端流量(管理员)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ 错误:\n\n {{ .error }}", "using_default_value": "好的,我会使用默认值。 😊", "incorrect_input": "您的输入无效。\n短语应连续输入,不能有空格。\n正确示例: aaaaaa\n错误示例: aaa aaa 🚫", + "broadcastAskText": "📤 群发:请把要发给客户的消息发给我 — 文本、照片、视频、文件或整个相册都可以。", + "broadcastAlreadyRunning": "❗ 已有群发正在进行,请等待其完成。", + "broadcastNoRecipients": "❗ 没有绑定 Telegram ID 的客户端。", + "broadcastPreview": "📤 该消息将发送给 {{ .Count }} 位接收者。发送吗?", + "broadcastNotCopyable": "❗ 该消息无法复制用于群发,请发送其他消息。", + "broadcastUnreachable": "ℹ️ {{ .Count }} 位接收者从未启动过机器人(或已将其拉黑),无法发送消息 — 请让他们点击 Start。", + "broadcastProgress": "📤 已发送 {{ .Sent }}/{{ .Total }}(失败:{{ .Failed }})\r\n", + "broadcastFinished": "✅ 群发完成。\r\n👥 接收者:{{ .Total }}\r\n📨 已送达:{{ .Sent }}\r\n🚫 失败:{{ .Failed }}\r\n⏭ 已跳过:{{ .Skipped }}\r\n⏱ 耗时:{{ .Time }}\r\n", + "broadcastCanceled": "🛑 群发已取消。\r\n👥 接收者:{{ .Total }}\r\n📨 已送达:{{ .Sent }}\r\n🚫 失败:{{ .Failed }}\r\n⏭ 已跳过:{{ .Skipped }}\r\n⏱ 耗时:{{ .Time }}\r\n", "AreYouSure": "你确定吗?🤔", "SuccessResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功", "FailedResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ 关闭键盘", "cancel": "❌ 取消", + "broadcastSend": "📤 立即发送", "cancelReset": "❌ 取消重置", "cancelIpLimit": "❌ 取消 IP 限制", "confirmResetTraffic": "✅ 确认重置流量?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "排序的流量使用报告" }, "answers": { + "broadcastStarted": "🚀 群发已开始。", + "broadcastCanceling": "🛑 群发将在当前接收者之后停止。", + "broadcastCanceled": "❌ 群发已取消。", "successfulOperation": "✅ 成功!", "errorOperation": "❗ 操作错误。", "getInboundsFailed": "❌ 获取入站信息失败。", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index bf664317b..bd1272945 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2425,6 +2425,7 @@ "usageDesc": "查看客戶端用量:/usage 郵箱", "inboundDesc": "搜尋入站:/inbound 備註(管理員)", "restartDesc": "重啟 Xray 核心(管理員)", + "broadcastDesc": "向所有客戶端群發訊息(管理員)", "clearallDesc": "重置所有客戶端流量(管理員)" }, "messages": { @@ -2481,6 +2482,15 @@ "error_add_client": "⚠️ 錯誤:\n\n {{ .error }}", "using_default_value": "好的,我會使用預設值。 😊", "incorrect_input": "您的輸入無效。\n短語應連續輸入,不能有空格。\n正確示例: aaaaaa\n錯誤示例: aaa aaa 🚫", + "broadcastAskText": "📤 群發:請把要發給客戶的訊息傳給我 — 文字、照片、影片、檔案或整個相簿都可以。", + "broadcastAlreadyRunning": "❗ 已有群發正在進行,請等待其完成。", + "broadcastNoRecipients": "❗ 沒有綁定 Telegram ID 的客戶端。", + "broadcastPreview": "📤 此訊息將傳送給 {{ .Count }} 位收件人。要傳送嗎?", + "broadcastNotCopyable": "❗ 此訊息無法複製用於群發,請傳送其他訊息。", + "broadcastUnreachable": "ℹ️ {{ .Count }} 位收件人從未啟動過機器人(或已將其封鎖),無法傳送訊息 — 請請他們按下 Start。", + "broadcastProgress": "📤 已傳送 {{ .Sent }}/{{ .Total }}(失敗:{{ .Failed }})\r\n", + "broadcastFinished": "✅ 群發完成。\r\n👥 收件人:{{ .Total }}\r\n📨 已送達:{{ .Sent }}\r\n🚫 失敗:{{ .Failed }}\r\n⏭ 已跳過:{{ .Skipped }}\r\n⏱ 費時:{{ .Time }}\r\n", + "broadcastCanceled": "🛑 群發已取消。\r\n👥 收件人:{{ .Total }}\r\n📨 已送達:{{ .Sent }}\r\n🚫 失敗:{{ .Failed }}\r\n⏭ 已跳過:{{ .Skipped }}\r\n⏱ 費時:{{ .Time }}\r\n", "AreYouSure": "你確定嗎?🤔", "SuccessResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功", "FailedResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]", @@ -2499,6 +2509,7 @@ "buttons": { "closeKeyboard": "❌ 關閉鍵盤", "cancel": "❌ 取消", + "broadcastSend": "📤 立即傳送", "cancelReset": "❌ 取消重置", "cancelIpLimit": "❌ 取消 IP 限制", "confirmResetTraffic": "✅ 確認重置流量?", @@ -2539,6 +2550,9 @@ "SortedTrafficUsageReport": "排序過的流量使用報告" }, "answers": { + "broadcastStarted": "🚀 群發已開始。", + "broadcastCanceling": "🛑 群發將在目前收件人之後停止。", + "broadcastCanceled": "❌ 群發已取消。", "successfulOperation": "✅ 成功!", "errorOperation": "❗ 操作錯誤。", "getInboundsFailed": "❌ 獲取入站資訊失敗。",