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:
Muse
2026-10-21 17:16:32 +00:00
parent 5031a70ccb
commit ceca96571d
49 changed files with 8351 additions and 50 deletions
+126 -1
View File
@@ -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()