Files
3x-ui/internal/web/service/tgbot/tgbot.go
T
DIMFLIX 0ef94b686e feat(tgbot): add /broadcast to relay an admin message to all clients (#6510)
* feat(tgbot): add /broadcast to relay an admin message to all clients

Admins had no way to reach every client at once: notifications only
cover exhausted quotas, so an operator had to copy a message to each
client chat by hand. Add an admin-only /broadcast flow to the bot:

- /broadcast asks for a message; any message the admin sends — text,
  rich text, photo, video, file, sticker or a whole album — becomes the
  broadcast by reference (admin chat + message ids), and a preview
  self-copy shows the admin exactly what recipients will get while
  rejecting content Telegram cannot copy before the run starts.
- The draft references the original instead of parsing its content, so
  copyMessage/copyMessages deliver everything 1:1 on behalf of the bot
  with no forward header (the admin's identity stays private), no
  caption length pitfalls, and future Telegram message types work
  without new parsing.
- A media group arrives as separate updates; its ids are buffered with
  a short debounce, sorted, and delivered as one copyMessages call so
  recipients see the original album.
- Delivery runs in a background goroutine (common.GoRecover): sequential
  sends with a small pause, 429 retry_after honored per recipient,
  failures counted without stopping the run, progress edited into one
  card at most every 25 sends or 3 seconds, a cancel button checked
  between sends, and a final delivered/failed/skipped summary. The
  summary is edited into the card (only sent separately if the card is
  gone), so it is never duplicated.
- Recipients repeat the notifyExhausted walk: clients with a linked
  tg_id, deduplicated, admins excluded — they already receive the
  reports. The message content is never logged.

New i18n keys are added to all 13 locales.

* fix(tgbot): harden broadcast composition per review

- Key the composition per admin chat instead of one process-wide draft:
  two admins can now compose at once without dropping each other's
  drafts, and one admin's /broadcast no longer wipes another chat's
  half-collected album.
- Bind each preview card to its own draft via a random token carried in
  the confirm callback, so a stale Send tap is answered with an error
  instead of delivering a newer, unapproved draft.
- Ignore non-admin senders while a chat composes: the awaiting state is
  keyed by chat id, and in a group that chat is shared.
- Check the cancel flag inside the flood-control retry loop, so a 429
  with a long retry_after no longer holds the single broadcast slot
  after the admin cancelled.
- Scale the per-recipient pause by the copied batch size, so an album
  keeps the same per-second ceiling as a single message.
- Trim the comment blocks that exceeded the two-line cap.

* fix(tgbot): reset broadcast state on stop and classify 403 as skipped

- Clear compositions and cancel the active run from StopBot, next to the
  per-chat draft resets: an album debounce timer, a confirmable token or
  a held runner slot must not outlive the receiver that created them.
- Sleep flood-control waits in 5 s slices and re-check cancel and bot
  state between them, so a minutes-long retry_after no longer parks the
  single-runner slot after the admin cancelled or the bot stopped.
- Count Telegram 403 (the chat never started the bot, or blocked it) as
  skipped instead of failed, log it at debug rather than one warning per
  recipient, and append one line to the summary naming the reason.
- Trim the remaining comment blocks over the two-line cap.

* fix(tgbot): count unreachable recipients in broadcast progress throttle

The progress card refresh was keyed on sent+failed, which a 403 does not
advance since unreachable chats were split out of the failure count. A
streak of unreachable recipients while that sum sat on a multiple of
broadcastProgressEvery (0 included, so from the very first recipient)
edited the card once per chat, doubling the request rate the send delay
is sized for and defeating the throttle. Count processed recipients.

* fix(tgbot): key broadcast compositions by admin, not chat

After #6604 moved conversation state to the admin (chatUser), the
broadcast draft map stayed keyed by chat. Two admins composing in one
group then shared a slot: the second admin's message dropped the first
admin's draft, whose Send tap answered "went wrong" while only the other
draft could go out - the same class #6604 fixed for the add-client
wizard. Drafts, album buffers and confirm tokens now live under the
admin who ran /broadcast.

The router now hands handleBroadcastInput only the admin whose own
/broadcast is awaiting input, so its sender re-check and the test that
fed it a non-admin message directly (an input no route can deliver)
are removed.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-27 01:43:32 +02:00

608 lines
17 KiB
Go

package tgbot
import (
"context"
"crypto/rand"
"embed"
"math/big"
"net/http"
"net/url"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mymmrac/telego"
th "github.com/mymmrac/telego/telegohandler"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
)
var (
bot *telego.Bot
// botCancel stores the function to cancel the context, stopping Long Polling gracefully.
botCancel context.CancelFunc
// tgBotMutex protects concurrent access to botCancel variable
tgBotMutex sync.Mutex
// botWG waits for the OnReceive Long Polling goroutine to finish.
botWG sync.WaitGroup
botHandler *th.BotHandler
adminIds []int64
isRunning bool
hostname string
hashStorage *global.HashStorage
// EventBus is set from web layer to publish login/security events.
EventBus *eventbus.Bus
// Performance improvements
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
// Simple cache for frequently accessed data
statusCache struct {
data *service.Status
timestamp time.Time
mutex sync.RWMutex
}
serverStatsCache struct {
data string
timestamp time.Time
mutex sync.RWMutex
}
)
// clientDraft is one chat's add-client wizard state. Per-protocol secrets are
// filled per-inbound on submit, so only the universal fields live here.
type clientDraft struct {
sync.Mutex
receiverInboundID int
receiverInboundIDs []int
email string
limitIP int
totalGB int64
expiryTime int64
enable bool
tgID string
subID string
comment string
reset int
}
// chatUser names the admin a wizard belongs to. A private chat's ids are equal;
// in a group they are not, and each admin at its keyboard fills in their own.
type chatUser struct {
chatID int64
userID int64
}
// messageActor reads the sender off a message. A post without one (a channel)
// keys to user 0, an id no admin can hold.
func messageActor(message telego.Message) chatUser {
if message.From == nil {
return chatUser{chatID: message.Chat.ID}
}
return chatUser{chatID: message.Chat.ID, userID: message.From.ID}
}
// callbackActor reads the admin who tapped the button, not the chat the keyboard
// sits in: every admin in a group sees the same keyboard.
func callbackActor(callbackQuery *telego.CallbackQuery) chatUser {
return chatUser{chatID: callbackQuery.Message.GetChat().ID, userID: callbackQuery.From.ID}
}
// clientDrafts keys a draft by the admin filling it in: the steps arrive on the
// worker pool, so one draft let two admins fill in one client between them.
type clientDrafts struct {
mu sync.Mutex
drafts map[chatUser]*clientDraft
}
var addClientDrafts = &clientDrafts{drafts: make(map[chatUser]*clientDraft)}
func (s *clientDrafts) forActor(actor chatUser) *clientDraft {
s.mu.Lock()
defer s.mu.Unlock()
draft, ok := s.drafts[actor]
if !ok {
draft = &clientDraft{}
s.drafts[actor] = draft
}
return draft
}
func (s *clientDrafts) reset(actor chatUser) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.drafts, actor)
}
// isAddClientStep reports whether callback data belongs to the add-client
// wizard, the only flow that reads or writes a draft.
func isAddClientStep(data string) bool {
return strings.HasPrefix(data, "add_client")
}
func (s *clientDrafts) resetAll() {
s.mu.Lock()
defer s.mu.Unlock()
s.drafts = make(map[chatUser]*clientDraft)
}
// userStateStore guards the per-admin conversation states. The Telegram command
// and callback handlers run on a worker-pool goroutine while the message handler
// runs on the dispatch goroutine, so a bare map would be a concurrent-map-write
// crash. It also expires abandoned conversations so a user who starts a flow and
// goes silent doesn't leave an entry forever.
type userStateStore struct {
mu sync.Mutex
states map[chatUser]userStateEntry
lastPrune time.Time
}
type userStateEntry struct {
state string
at time.Time
}
var userStateMgr = &userStateStore{states: make(map[chatUser]userStateEntry)}
func (s *userStateStore) set(actor chatUser, state string) {
s.mu.Lock()
s.states[actor] = userStateEntry{state: state, at: time.Now()}
s.mu.Unlock()
}
func (s *userStateStore) get(actor chatUser) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.states[actor]
return e.state, ok
}
func (s *userStateStore) clear(actor chatUser) {
s.mu.Lock()
delete(s.states, actor)
s.mu.Unlock()
}
func (s *userStateStore) reset() {
s.mu.Lock()
s.states = make(map[chatUser]userStateEntry)
s.mu.Unlock()
}
// maybePrune drops conversations older than maxAge, at most once per maxAge so a
// busy bot doesn't sweep the whole map on every message.
func (s *userStateStore) maybePrune(maxAge time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if now.Sub(s.lastPrune) < maxAge {
return
}
s.lastPrune = now
for id, e := range s.states {
if now.Sub(e.at) > maxAge {
delete(s.states, id)
}
}
}
// LoginStatus represents the result of a login attempt.
type LoginStatus byte
// Login status constants
const (
LoginSuccess LoginStatus = 1 // Login was successful
LoginFail LoginStatus = 0 // Login failed
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
)
// LoginAttempt contains safe metadata for panel login notifications.
// It intentionally does not include attempted passwords.
type LoginAttempt struct {
Username string
IP string
Time string
Status LoginStatus
Reason string
}
// Tgbot provides business logic for Telegram bot integration.
// It handles bot commands, user interactions, and status reporting via Telegram.
type Tgbot struct {
inboundService service.InboundService
clientService service.ClientService
settingService service.SettingService
serverService service.ServerService
xrayService service.XrayService
lastStatus *service.Status
}
// NewTgbot creates a new Tgbot instance.
func (t *Tgbot) NewTgbot() *Tgbot {
return new(Tgbot)
}
// I18nBot retrieves a localized message for the bot interface.
func (t *Tgbot) I18nBot(name string, params ...string) string {
return locale.I18n(locale.Bot, name, params...)
}
// GetHashStorage returns the hash storage instance for callback queries.
func (t *Tgbot) GetHashStorage() *global.HashStorage {
return hashStorage
}
// getCachedStatus returns cached server status if it's fresh enough (less than 5 seconds old)
func (t *Tgbot) getCachedStatus() (*service.Status, bool) {
statusCache.mutex.RLock()
defer statusCache.mutex.RUnlock()
if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second {
return statusCache.data, true
}
return nil, false
}
// setCachedStatus updates the status cache
func (t *Tgbot) setCachedStatus(status *service.Status) {
statusCache.mutex.Lock()
defer statusCache.mutex.Unlock()
statusCache.data = status
statusCache.timestamp = time.Now()
}
// getCachedServerStats returns cached server stats if it's fresh enough (less than 10 seconds old)
func (t *Tgbot) getCachedServerStats() (string, bool) {
serverStatsCache.mutex.RLock()
defer serverStatsCache.mutex.RUnlock()
if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second {
return serverStatsCache.data, true
}
return "", false
}
// setCachedServerStats updates the server stats cache
func (t *Tgbot) setCachedServerStats(stats string) {
serverStatsCache.mutex.Lock()
defer serverStatsCache.mutex.Unlock()
serverStatsCache.data = stats
serverStatsCache.timestamp = time.Now()
}
// Start initializes and starts the Telegram bot with the provided translation files.
func (t *Tgbot) Start(i18nFS embed.FS) error {
// Initialize localizer
err := locale.InitLocalizer(i18nFS, &t.settingService)
if err != nil {
return err
}
// If Start is called again (e.g. during reload), ensure any previous long-polling
// loop is stopped before creating a new bot / receiver.
StopBot()
// Initialize hash storage to store callback queries
hashStorage = global.NewHashStorage(20 * time.Minute)
// Initialize worker pool for concurrent message processing (max 10 concurrent handlers)
messageWorkerPool = make(chan struct{}, 10)
// Initialize optimized HTTP client with connection pooling
optimizedHTTPClient = &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: false,
},
}
t.SetHostname()
// Get Telegram bot token
tgBotToken, err := t.settingService.GetTgBotToken()
if err != nil || tgBotToken == "" {
logger.Warning("Failed to get Telegram bot token:", err)
return err
}
// Get Telegram bot chat ID(s)
tgBotID, err := t.settingService.GetTgBotChatId()
if err != nil {
logger.Warning("Failed to get Telegram bot chat ID:", err)
return err
}
parsedAdminIds := make([]int64, 0)
// Parse admin IDs from comma-separated string
if tgBotID != "" {
for adminID := range strings.SplitSeq(tgBotID, ",") {
id, err := strconv.ParseInt(adminID, 10, 64)
if err != nil {
logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
return err
}
parsedAdminIds = append(parsedAdminIds, id)
}
}
tgBotMutex.Lock()
adminIds = parsedAdminIds
tgBotMutex.Unlock()
// Get Telegram bot proxy URL
tgBotProxy, err := t.settingService.GetTgBotProxy()
if err != nil {
logger.Warning("Failed to get Telegram bot proxy URL:", err)
}
// Fall back to the panel-wide egress bridge when no dedicated bot proxy is
// set. Resolved once at bot start: if Xray comes up later, the bot keeps
// its direct connection until it is restarted.
if tgBotProxy == "" {
if egress := t.settingService.PanelEgressProxyURL(); egress != "" && isSupportedBotProxyScheme(egress) {
tgBotProxy = egress
}
}
// Get Telegram bot API server URL
tgBotAPIServer, err := t.settingService.GetTgBotAPIServer()
if err != nil {
logger.Warning("Failed to get Telegram bot API server URL:", err)
}
// Create new Telegram bot instance
bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer)
if err != nil {
logger.Error("Failed to initialize Telegram bot API:", err)
return err
}
t.trySetBotCommands(bot)
// Start receiving Telegram bot messages
tgBotMutex.Lock()
alreadyRunning := isRunning || botCancel != nil
tgBotMutex.Unlock()
if !alreadyRunning {
logger.Info("Telegram bot receiver started")
go t.OnReceive()
}
return nil
}
func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
defer func() {
if r := recover(); r != nil {
logger.Warning("Failed to register bot commands (Telegram may be rate-limiting); bot will continue without them:", r)
}
}()
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
Commands: []telego.BotCommand{
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
{Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")},
{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 {
logger.Warning("Failed to set bot commands:", err)
}
}
func isSupportedBotProxyScheme(proxyUrl string) bool {
return strings.HasPrefix(proxyUrl, "socks5://") ||
strings.HasPrefix(proxyUrl, "http://") ||
strings.HasPrefix(proxyUrl, "https://")
}
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
client := &fasthttp.Client{
// Connection timeouts
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
MaxIdleConnDuration: 60 * time.Second,
MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration
MaxIdemponentCallAttempts: 3,
ReadBufferSize: 4096,
WriteBufferSize: 4096,
MaxConnsPerHost: 100,
MaxConnWaitTimeout: 10 * time.Second,
DisableHeaderNamesNormalizing: false,
DisablePathNormalizing: false,
// resetTimeout stays false to keep the pre-RetryIfErr retry timing.
RetryIfErr: func(request *fasthttp.Request, _ int, _ error) (bool, bool) {
method := string(request.Header.Method())
return false, method == "GET" || method == "POST"
},
}
if proxyUrl != "" {
if strings.HasPrefix(proxyUrl, "socks5://") {
client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
} else {
client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxyUrl)
}
}
return client
}
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
// Validate proxy URL if provided
if proxyUrl != "" {
if !isSupportedBotProxyScheme(proxyUrl) {
logger.Warning("Unsupported proxy scheme (want socks5:// or http(s)://), ignoring proxy")
proxyUrl = "" // Clear invalid proxy
} else if _, err := url.Parse(proxyUrl); err != nil {
logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
proxyUrl = ""
}
}
// Validate API server URL if provided
if apiServerUrl != "" {
safeURL, err := service.SanitizePublicHTTPURL(apiServerUrl, false)
if err != nil {
logger.Warningf("Invalid or blocked API server URL, using default: %v", err)
apiServerUrl = ""
} else {
apiServerUrl = safeURL
}
}
// Create robust fasthttp client
client := t.createRobustFastHTTPClient(proxyUrl)
// Build bot options
var options []telego.BotOption
options = append(options, telego.WithFastHTTPClient(client))
if apiServerUrl != "" {
options = append(options, telego.WithAPIServer(apiServerUrl))
}
return telego.NewBot(token, options...)
}
// IsRunning checks if the Telegram bot is currently running.
func (t *Tgbot) IsRunning() bool {
tgBotMutex.Lock()
defer tgBotMutex.Unlock()
return isRunning
}
// adminSnapshot returns the admin chat list under the mutex Start and Stop
// replace it under: a torn slice header is not a harmless race.
func adminSnapshot() []int64 {
tgBotMutex.Lock()
defer tgBotMutex.Unlock()
return slices.Clone(adminIds)
}
// SetHostname sets the hostname for the bot.
func (t *Tgbot) SetHostname() {
host, err := os.Hostname()
if err != nil {
logger.Error("get hostname error:", err)
hostname = ""
return
}
hostname = host
}
// Stop safely stops the Telegram bot's Long Polling operation.
// This method now calls the global StopBot function and cleans up other resources.
func (t *Tgbot) Stop() {
StopBot()
logger.Info("Stop Telegram receiver ...")
tgBotMutex.Lock()
adminIds = nil
tgBotMutex.Unlock()
}
// StopBot safely stops the Telegram bot's Long Polling operation by cancelling its context.
// This is the global function called from main.go's signal handler and t.Stop().
func StopBot() {
// Don't hold the mutex while cancelling/waiting.
tgBotMutex.Lock()
cancel := botCancel
botCancel = nil
handler := botHandler
botHandler = nil
isRunning = false
tgBotMutex.Unlock()
userStateMgr.reset()
addClientDrafts.resetAll()
broadcastResetAll()
if handler != nil {
_ = handler.Stop()
}
if cancel != nil {
logger.Info("Sending cancellation signal to Telegram bot...")
// Cancels the context passed to UpdatesViaLongPolling; this closes updates channel
// and lets botHandler.Start() exit cleanly.
cancel()
botWG.Wait()
logger.Info("Telegram bot successfully stopped.")
}
}
// encodeQuery encodes the query string if it's longer than 64 characters.
func (t *Tgbot) encodeQuery(query string) string {
// NOTE: we only need to hash for more than 64 chars
if len(query) <= 64 {
return query
}
return hashStorage.SaveHash(query)
}
// decodeQuery decodes a hashed query string back to its original form.
func (t *Tgbot) decodeQuery(query string) (string, error) {
if !hashStorage.IsMD5(query) {
return query, nil
}
decoded, exists := hashStorage.GetValue(query)
if !exists {
return "", common.NewError("hash not found in storage!")
}
return decoded, nil
}
// randomLowerAndNum generates a random string of lowercase letters and numbers.
func (t *Tgbot) randomLowerAndNum(length int) string {
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
bytes := make([]byte, length)
for i := range bytes {
randomIndex, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
bytes[i] = charset[randomIndex.Int64()]
}
return string(bytes)
}
// int64Contains checks if an int64 slice contains a specific item.
func int64Contains(slice []int64, item int64) bool {
return slices.Contains(slice, item)
}
// isSingleWord checks if the text contains only a single word.
func (t *Tgbot) isSingleWord(text string) bool {
text = strings.TrimSpace(text)
re := regexp.MustCompile(`\s+`)
return re.MatchString(text)
}