P9(5): restore upstream Telegram bot (settings, backup, notifications)
Per user decision 2026-10-21 (reverses the 2026-10-20 drop-Telegram call): port the upstream 3x-ui v3.7.0-generation Telegram bot wholesale instead of rewriting a minimal set, adapted to this fork. - new internal/web/service/tgbot package (telego): bot lifecycle, commands, reports, callback router, send helpers; token validation via telego; custom API server URL passes the fork's SSRF sanitizer (loopback/private bases fall back to the default endpoint, logged) - settings: full upstream key set (tgBotEnable/Token/Proxy/APIServer/ ChatId, tgRunTime, tgBotBackup, tgCpu/tgMemory, tgLang, tgEnabledEvents) with getters/setters; settings page gains a Telegram tab + sidebar entry; token shown masked, clearTgBotToken honored - wiring (web.go): stats-notify cron registered from tgRunTime when the bot is enabled; CheckHashStorage job; CPU/memory alarm jobs gated on tgEnabledEvents + thresholds; login notifications via event bus; POST /panel/api/setting/testTgBot and /panel/api/backuptotgbot - lite adaptation: StatsNotifyJob no longer gates on a local Xray core (this panel never runs one) so the periodic report and the scheduled DB backup (sendDocument) can actually fire - translations: upstream TG keys restored across all locales End-to-end smoke (real binary + fake Telegram API over HTTPS at a public-looking address, browser form wire format): bot starts on settings change (getMe), testTgBot delivers, panel restart re-arms the bot, tgRunTime='@every 1m' cron really fires sendDocument carrying the panel SQLite DB (magic verified) plus the report sendMessage, login notification delivered, dead-endpoint failure is logged not silent. 13/13 PASS — see P9-REPORT.md.
This commit is contained in:
+126
-1
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
@@ -118,6 +119,7 @@ type Server struct {
|
||||
|
||||
xrayService service.XrayService
|
||||
settingService service.SettingService
|
||||
tgbotService tgbot.Tgbot
|
||||
|
||||
wsHub *websocket.Hub
|
||||
|
||||
@@ -218,7 +220,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
|
||||
// init i18n — still used by backend strings (errors, log messages,
|
||||
// SubPage menu entries) even though the Go template engine is gone.
|
||||
err = locale.InitLocalizer(i18nFS)
|
||||
err = locale.InitLocalizer(i18nFS, &s.settingService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -298,6 +300,7 @@ const (
|
||||
cadenceXrayLogPrune = "@every 10m"
|
||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
|
||||
cadenceCheckHash = "@every 2m"
|
||||
cadenceCPUAlarm = "@every 1m"
|
||||
cadenceMemoryAlarm = "@every 1m"
|
||||
)
|
||||
@@ -352,6 +355,35 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
// Check monthly reset days at midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
||||
|
||||
// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
if (err == nil) && isTgbotenabled {
|
||||
runtime, err := s.settingService.GetTgbotRuntime()
|
||||
if err != nil {
|
||||
logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
|
||||
runtime = "@daily"
|
||||
} else if strings.TrimSpace(runtime) == "" {
|
||||
logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
|
||||
runtime = "@daily"
|
||||
}
|
||||
logger.Infof("Tg notify enabled,run at %s", runtime)
|
||||
if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
|
||||
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
|
||||
}
|
||||
|
||||
// check for Telegram bot callback query hash storage reset
|
||||
_, _ = s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
|
||||
}
|
||||
|
||||
// CPU/memory monitors publish cpu.high / memory.high events; register them
|
||||
// whenever a notifier (Telegram) wants them, independent of the bot being on.
|
||||
if s.cpuAlarmWanted() {
|
||||
_, _ = s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
|
||||
}
|
||||
if s.memoryAlarmWanted() {
|
||||
_, _ = s.cron.AddJob(cadenceMemoryAlarm, job.NewCheckMemJob())
|
||||
}
|
||||
|
||||
if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 {
|
||||
_, _ = s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob())
|
||||
go func() {
|
||||
@@ -361,6 +393,54 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
}
|
||||
}
|
||||
|
||||
// cpuAlarmWanted reports whether any notifier is configured to receive
|
||||
// cpu.high events with a positive threshold.
|
||||
func (s *Server) cpuAlarmWanted() bool {
|
||||
wants := func(events string, threshold int) bool {
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
for e := range strings.SplitSeq(events, ",") {
|
||||
if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
||||
events, _ := s.settingService.GetTgEnabledEvents()
|
||||
cpu, _ := s.settingService.GetTgCpu()
|
||||
if wants(events, cpu) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// memoryAlarmWanted reports whether any notifier is configured to receive
|
||||
// memory.high events with a positive threshold.
|
||||
func (s *Server) memoryAlarmWanted() bool {
|
||||
wants := func(events string, threshold int) bool {
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
for e := range strings.SplitSeq(events, ",") {
|
||||
if strings.TrimSpace(e) == string(eventbus.EventMemoryHigh) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
||||
events, _ := s.settingService.GetTgEnabledEvents()
|
||||
mem, _ := s.settingService.GetTgMemory()
|
||||
if wants(events, mem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Start initializes and starts the web server with configured settings, routes, and background jobs.
|
||||
func (s *Server) Start() (err error) {
|
||||
return s.start(true)
|
||||
@@ -492,6 +572,41 @@ func (s *Server) start(restartXray bool) (err error) {
|
||||
// Create event bus before startTask so jobs can use it
|
||||
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
||||
job.EventBus = s.bus
|
||||
tgbot.EventBus = s.bus
|
||||
|
||||
// Wire Telegram test function to controller
|
||||
controller.SetTestTgFunc(func() error {
|
||||
if !s.tgbotService.IsRunning() {
|
||||
return fmt.Errorf("telegram bot is not running (check token and chat ID)")
|
||||
}
|
||||
if err := s.tgbotService.TestConnection(); err != nil {
|
||||
return fmt.Errorf("telegram API test failed: %w", err)
|
||||
}
|
||||
s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui")
|
||||
return nil
|
||||
})
|
||||
|
||||
controller.SetReloadTgbotFunc(func() {
|
||||
enabled, err := s.settingService.GetTgbotEnabled()
|
||||
if err != nil || !enabled {
|
||||
if s.tgbotService.IsRunning() {
|
||||
s.tgbotService.Stop()
|
||||
}
|
||||
if s.bus != nil {
|
||||
s.bus.Unsubscribe("tg-notifier")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Start() stops any previous receiver first, so it is safe whether or not the bot is already running.
|
||||
tgBot := s.tgbotService.NewTgbot()
|
||||
if startErr := tgBot.Start(i18nFS); startErr != nil {
|
||||
logger.Warning("reload Telegram bot failed:", startErr)
|
||||
return
|
||||
}
|
||||
if s.bus != nil {
|
||||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
||||
}
|
||||
})
|
||||
|
||||
// Wire xray crash callback BEFORE startTask so it's ready
|
||||
xray.OnCrash = func(err error) {
|
||||
@@ -505,6 +620,13 @@ func (s *Server) start(restartXray bool) (err error) {
|
||||
|
||||
s.startTask(restartXray, loc)
|
||||
|
||||
if isTgbotenabled, err := s.settingService.GetTgbotEnabled(); (err == nil) && isTgbotenabled {
|
||||
tgBot := s.tgbotService.NewTgbot()
|
||||
_ = tgBot.Start(i18nFS)
|
||||
// Subscribe Telegram notifications for event bus
|
||||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -533,6 +655,9 @@ func (s *Server) stop(stopXray bool) error {
|
||||
if stopXray {
|
||||
service.StopTrafficWriter()
|
||||
}
|
||||
if s.tgbotService.IsRunning() {
|
||||
s.tgbotService.Stop()
|
||||
}
|
||||
// Gracefully stop WebSocket hub
|
||||
if s.wsHub != nil {
|
||||
s.wsHub.Stop()
|
||||
|
||||
Reference in New Issue
Block a user