From e42b5d770d1e32a365be8a7e6d92b18dfbebb21f Mon Sep 17 00:00:00 2001 From: Muse Date: Tue, 20 Oct 2026 10:11:35 +0000 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80:=20=E5=8E=BB=E6=8E=89=20Tele?= =?UTF-8?q?gram=20=E6=9C=BA=E5=99=A8=E4=BA=BA+=E9=80=9A=E7=9F=A5:=20tgbot?= =?UTF-8?q?=20=E5=8C=85/stats-notify=20=E4=B8=8E=20hash=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20job/=E7=99=BB=E5=BD=95=E9=80=9A=E7=9F=A5/backuptotg?= =?UTF-8?q?bot/testTgBot/=E5=85=A8=E9=83=A8=20tg*=20=E8=AE=BE=E7=BD=AE/loc?= =?UTF-8?q?ale=20Bot=20=E6=9C=AC=E5=9C=B0=E5=8C=96/SecretClears=20?= =?UTF-8?q?=E6=9C=BA=E5=88=B6;=20=E8=BF=9E=E5=B8=A6=E6=B8=85=E7=90=86=20Sw?= =?UTF-8?q?agger/=E5=A4=96=E9=83=A8=E6=B5=81=E9=87=8F/=E9=82=AE=E4=BB=B6/2?= =?UTF-8?q?FA=20=E7=9A=84=E6=AE=8B=E7=95=99=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/web/controller/agent.go | 16 +- internal/web/controller/api.go | 8 - internal/web/controller/dist_test.go | 35 - internal/web/controller/index.go | 23 - internal/web/controller/node.go | 6 +- internal/web/controller/setting.go | 49 +- internal/web/controller/setting_test.go | 83 - internal/web/entity/check_valid_test.go | 25 - internal/web/entity/entity.go | 13 - internal/web/job/check_hash_storage.go | 22 - internal/web/job/check_hash_storage_test.go | 12 - internal/web/job/stats_notify_job.go | 33 - internal/web/job/xray_traffic_inform_test.go | 91 - internal/web/locale/locale.go | 29 +- .../web/service/inbound_tgbot_lookup_test.go | 67 - internal/web/service/inbound_traffic.go | 70 - internal/web/service/setting.go | 137 +- internal/web/service/setting_clash_test.go | 8 +- .../service/setting_factory_defaults_test.go | 6 - internal/web/service/setting_security_test.go | 142 -- .../web/service/setting_sub_identity_test.go | 4 +- internal/web/service/tgbot/numeric_input.go | 17 - .../web/service/tgbot/numeric_input_test.go | 81 - internal/web/service/tgbot/tgbot.go | 537 ------ .../service/tgbot/tgbot_callback_auth_test.go | 40 - internal/web/service/tgbot/tgbot_client.go | 782 --------- .../service/tgbot/tgbot_delete_after_test.go | 18 - internal/web/service/tgbot/tgbot_event.go | 162 -- internal/web/service/tgbot/tgbot_inbound.go | 306 ---- internal/web/service/tgbot/tgbot_report.go | 509 ------ internal/web/service/tgbot/tgbot_router.go | 1492 ----------------- internal/web/service/tgbot/tgbot_send.go | 296 ---- internal/web/service/tgbot/tgbot_send_test.go | 35 - internal/web/service/tgbot/tgbot_test.go | 125 -- internal/web/service/xray.go | 2 +- internal/web/web.go | 143 +- main.go | 79 - 37 files changed, 32 insertions(+), 5471 deletions(-) delete mode 100644 internal/web/job/check_hash_storage.go delete mode 100644 internal/web/job/check_hash_storage_test.go delete mode 100644 internal/web/job/stats_notify_job.go delete mode 100644 internal/web/job/xray_traffic_inform_test.go delete mode 100644 internal/web/service/inbound_tgbot_lookup_test.go delete mode 100644 internal/web/service/tgbot/numeric_input.go delete mode 100644 internal/web/service/tgbot/numeric_input_test.go delete mode 100644 internal/web/service/tgbot/tgbot.go delete mode 100644 internal/web/service/tgbot/tgbot_callback_auth_test.go delete mode 100644 internal/web/service/tgbot/tgbot_client.go delete mode 100644 internal/web/service/tgbot/tgbot_delete_after_test.go delete mode 100644 internal/web/service/tgbot/tgbot_event.go delete mode 100644 internal/web/service/tgbot/tgbot_inbound.go delete mode 100644 internal/web/service/tgbot/tgbot_report.go delete mode 100644 internal/web/service/tgbot/tgbot_router.go delete mode 100644 internal/web/service/tgbot/tgbot_send.go delete mode 100644 internal/web/service/tgbot/tgbot_send_test.go delete mode 100644 internal/web/service/tgbot/tgbot_test.go diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index 61dde3a..4039ce5 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -2,10 +2,10 @@ package controller import ( "crypto/rand" - "errors" "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "time" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -289,13 +289,13 @@ func (a *AgentController) listTokens(c *gin.Context) { out := make([]gin.H, 0, len(agents)) for _, ag := range agents { out = append(out, gin.H{ - "id": ag.Id, - "token_prefix": ag.TokenPrefix, - "name": ag.Name, - "node_id": ag.NodeID, - "last_seen": ag.LastSeen, - "has_egress": ag.Outbounds != "" || ag.RoutingRules != "", - "created_at": ag.CreatedAt, + "id": ag.Id, + "token_prefix": ag.TokenPrefix, + "name": ag.Name, + "node_id": ag.NodeID, + "last_seen": ag.LastSeen, + "has_egress": ag.Outbounds != "" || ag.RoutingRules != "", + "created_at": ag.CreatedAt, }) } jsonObj(c, out, nil) diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 09d79ac..cb9c866 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -7,7 +7,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "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/session" "github.com/gin-gonic/gin" @@ -25,7 +24,6 @@ type APIController struct { xraySettingController *XraySettingController userService panel.UserService apiTokenService panel.ApiTokenService - Tgbot tgbot.Tgbot } // NewAPIController creates a new APIController instance and initializes its routes. @@ -219,10 +217,4 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { NewSubBalancerController(api) // Extra routes - api.POST("/backuptotgbot", a.BackuptoTgbot) -} - -// BackuptoTgbot sends a backup of the panel data to Telegram bot admins. -func (a *APIController) BackuptoTgbot(c *gin.Context) { - a.Tgbot.SendBackupToAdmins() } diff --git a/internal/web/controller/dist_test.go b/internal/web/controller/dist_test.go index 13de071..0ac0b95 100644 --- a/internal/web/controller/dist_test.go +++ b/internal/web/controller/dist_test.go @@ -1,46 +1,11 @@ package controller import ( - "encoding/json" "strings" "testing" ) -func TestWithServerBasePath(t *testing.T) { - spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`) - out, err := withServerBasePath(spec, "/test/") - if err != nil { - t.Fatalf("withServerBasePath: %v", err) - } - - var doc map[string]any - if err := json.Unmarshal(out, &doc); err != nil { - t.Fatalf("unmarshal result: %v", err) - } - - servers, ok := doc["servers"].([]any) - if !ok || len(servers) != 1 { - t.Fatalf("servers = %v, want one entry", doc["servers"]) - } - srv, _ := servers[0].(map[string]any) - if srv["url"] != "/test" { - t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"]) - } - - if doc["openapi"] != "3.0.3" { - t.Errorf("openapi field not preserved: %v", doc["openapi"]) - } - if _, ok := doc["paths"].(map[string]any)["/p"]; !ok { - t.Errorf("paths content not preserved verbatim") - } -} - -func TestWithServerBasePathInvalidJSON(t *testing.T) { - if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil { - t.Errorf("expected error on invalid spec, got nil") - } -} func TestPWAHeadInjectionUsesRuntimeBasePath(t *testing.T) { tests := []struct { diff --git a/internal/web/controller/index.go b/internal/web/controller/index.go index f6c1cf7..b49e7d7 100644 --- a/internal/web/controller/index.go +++ b/internal/web/controller/index.go @@ -9,7 +9,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "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/session" "github.com/gin-gonic/gin" @@ -27,7 +26,6 @@ type IndexController struct { settingService service.SettingService userService panel.UserService - tgbot tgbot.Tgbot } // NewIndexController creates a new IndexController and initializes its routes. @@ -75,17 +73,9 @@ func (a *IndexController) login(c *gin.Context) { remoteIP := getRemoteIp(c) safeUser := template.HTMLEscapeString(form.Username) - timeStr := time.Now().Format("2006-01-02 15:04:05") if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok { reason := "too many failed attempts" logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339)) - a.tgbot.UserLoginNotify(tgbot.LoginAttempt{ - Username: safeUser, - IP: remoteIP, - Time: timeStr, - Status: tgbot.LoginFail, - Reason: reason, - }) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) return } @@ -99,25 +89,12 @@ func (a *IndexController) login(c *gin.Context) { } else { logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason) } - a.tgbot.UserLoginNotify(tgbot.LoginAttempt{ - Username: safeUser, - IP: remoteIP, - Time: timeStr, - Status: tgbot.LoginFail, - Reason: reason, - }) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) return } defaultLoginLimiter.registerSuccess(remoteIP, form.Username) logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP) - a.tgbot.UserLoginNotify(tgbot.LoginAttempt{ - Username: safeUser, - IP: remoteIP, - Time: timeStr, - Status: tgbot.LoginSuccess, - }) if err := session.SetLoginUser(c, user); err != nil { logger.Warning("Unable to save session:", err) diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go index c69aac5..42462e0 100644 --- a/internal/web/controller/node.go +++ b/internal/web/controller/node.go @@ -176,9 +176,9 @@ func (a *NodeController) add(c *gin.Context) { return } jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{ - "node": view, - "agentToken": token, - "nodeType": "agent", + "node": view, + "agentToken": token, + "nodeType": "agent", }, nil) return } diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index a2cebf5..b5fbba8 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -70,7 +70,6 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/apiTokens/create", a.createApiToken) g.POST("/apiTokens/delete/:id", a.deleteApiToken) g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled) - g.POST("/testTgBot", a.testTgBot) } func (a *SettingController) validateRegex(c *gin.Context) { @@ -119,13 +118,7 @@ func (a *SettingController) updateSetting(c *gin.Context) { } allSetting := &form.AllSetting oldPanelOutbound, _ := a.settingService.GetPanelOutbound() - oldTgEnable, _ := a.settingService.GetTgbotEnabled() - oldTgToken, _ := a.settingService.GetTgBotToken() - oldTgChatId, _ := a.settingService.GetTgBotChatId() - oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer() - err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{ - TgBotToken: form.ClearTgBotToken, - }) + err := a.settingService.UpdateAllSetting(allSetting) if err == nil && form.PanelOutbound != oldPanelOutbound { // The egress bridge lives in the generated config; reconcile the // running core. One SOCKS inbound plus one routing rule β€” both @@ -134,16 +127,6 @@ func (a *SettingController) updateSetting(c *gin.Context) { logger.Warning("apply panel outbound change failed:", applyErr) } } - // UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare. - if err == nil && reloadTgbotFunc != nil { - tgChanged := oldTgEnable != allSetting.TgBotEnable || - (allSetting.TgBotEnable && (oldTgToken != allSetting.TgBotToken || - oldTgChatId != allSetting.TgBotChatId || - oldTgAPIServer != allSetting.TgBotAPIServer)) - if tgChanged { - reloadTgbotFunc() - } - } jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) } @@ -256,33 +239,3 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) { } jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled)) } - -func (a *SettingController) testTgBot(c *gin.Context) { - enabled, err := a.settingService.GetTgbotEnabled() - if err != nil || !enabled { - jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled")) - return - } - // Import tgbot package would create a circular dependency, so we call - // the test through the global function registered at startup. - if testTgFunc != nil { - if err := testTgFunc(); err != nil { - jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err) - return - } - jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil) - return - } - jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started")) -} - -// testTgFunc is set from web layer to test Telegram sending without circular imports. -var testTgFunc func() error - -// SetTestTgFunc registers the function used to test Telegram sending. -func SetTestTgFunc(fn func() error) { testTgFunc = fn } - -// reloadTgbotFunc is wired from the web layer; importing tgbot here would be a circular dependency. -var reloadTgbotFunc func() - -func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn } diff --git a/internal/web/controller/setting_test.go b/internal/web/controller/setting_test.go index 88ae236..5f00590 100644 --- a/internal/web/controller/setting_test.go +++ b/internal/web/controller/setting_test.go @@ -1,7 +1,6 @@ package controller import ( - "encoding/json" "net/http" "net/http/httptest" "path/filepath" @@ -14,7 +13,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) func TestValidateRegex(t *testing.T) { @@ -89,84 +87,3 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) { } } -// GHSA-xqqw-jqqv-99h6: a save that keeps 2FA enabled must not be able to -// rebind the authenticator without presenting a current code. -func TestUpdateSettingRequiresCodeToReplaceTwoFactorToken(t *testing.T) { - t.Setenv("XUI_DB_FOLDER", t.TempDir()) - if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { - t.Fatalf("InitDB: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) - - settingService := service.SettingService{} - if err := settingService.SetTwoFactorToken("ORIGINALSECRET234567"); err != nil { - t.Fatalf("seed token: %v", err) - } - if err := settingService.SetTwoFactorEnable(true); err != nil { - t.Fatalf("seed enable: %v", err) - } - - post := func(t *testing.T, mutate func(map[string]any)) string { - t.Helper() - base, err := settingService.GetAllSetting() - if err != nil { - t.Fatalf("GetAllSetting: %v", err) - } - raw, err := json.Marshal(base) - if err != nil { - t.Fatalf("marshal: %v", err) - } - body := map[string]any{} - if err := json.Unmarshal(raw, &body); err != nil { - t.Fatalf("unmarshal: %v", err) - } - mutate(body) - payload, err := json.Marshal(body) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - - gin.SetMode(gin.TestMode) - router := gin.New() - NewSettingController(router.Group("/panel/api")) - req := httptest.NewRequest(http.MethodPost, "/panel/api/setting/update", strings.NewReader(string(payload))) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - return resp.Body.String() - } - - t.Run("rebind without code is rejected", func(t *testing.T) { - got := post(t, func(body map[string]any) { - body["twoFactorEnable"] = true - body["twoFactorToken"] = "ATTACKERSECRET567890" - }) - if !strings.Contains(got, `"success":false`) { - t.Fatalf("rebind without a 2FA code was accepted: %s", got) - } - stored, err := settingService.GetTwoFactorToken() - if err != nil { - t.Fatalf("GetTwoFactorToken: %v", err) - } - if stored != "ORIGINALSECRET234567" { - t.Fatalf("stored 2FA secret = %q, want it unchanged", stored) - } - }) - - t.Run("ordinary save with redacted token still succeeds", func(t *testing.T) { - got := post(t, func(body map[string]any) { - body["twoFactorEnable"] = true - body["twoFactorToken"] = "" - }) - if !strings.Contains(got, `"success":true`) { - t.Fatalf("normal settings save was rejected: %s", got) - } - stored, err := settingService.GetTwoFactorToken() - if err != nil { - t.Fatalf("GetTwoFactorToken: %v", err) - } - if stored != "ORIGINALSECRET234567" { - t.Fatalf("stored 2FA secret = %q, want it preserved", stored) - } - }) -} diff --git a/internal/web/entity/check_valid_test.go b/internal/web/entity/check_valid_test.go index 5175127..4672b30 100644 --- a/internal/web/entity/check_valid_test.go +++ b/internal/web/entity/check_valid_test.go @@ -5,31 +5,6 @@ import ( "testing" ) -func TestCheckValidSmtpFrom(t *testing.T) { - base := func() *AllSetting { - return &AllSetting{WebPort: 2053, SubPort: 2096} - } - - for _, v := range []string{"", "panel@example.com"} { - s := base() - s.SmtpFrom = v - if err := s.CheckValid(); err != nil { - t.Errorf("CheckValid with smtpFrom=%q: unexpected error %v", v, err) - } - } - - for _, v := range []string{ - "not-an-address", - "panel@example.com\r\nBcc: evil@example.com", - "a@b\nSubject: injected", - } { - s := base() - s.SmtpFrom = v - if err := s.CheckValid(); err == nil { - t.Errorf("CheckValid with smtpFrom=%q: want error, got nil", v) - } - } -} func TestCheckValidWildcardListenPortConflict(t *testing.T) { s := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "0.0.0.0", SubListen: ""} diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 8baaf56..58ba3ef 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -36,18 +36,6 @@ type AllSetting struct { SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"` Datepicker string `json:"datepicker" form:"datepicker"` - TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` - TgBotToken string `json:"tgBotToken" form:"tgBotToken"` - TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` - TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` - TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` - TgRunTime string `json:"tgRunTime" form:"tgRunTime"` - TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` - TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` - TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"` - TgLang string `json:"tgLang" form:"tgLang"` - TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"` - OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"` TimeLocation string `json:"timeLocation" form:"timeLocation"` @@ -97,7 +85,6 @@ type AllSetting struct { type AllSettingView struct { AllSetting - HasTgBotToken bool `json:"hasTgBotToken"` HasApiToken bool `json:"hasApiToken"` HasWarpSecret bool `json:"hasWarpSecret"` HasNordSecret bool `json:"hasNordSecret"` diff --git a/internal/web/job/check_hash_storage.go b/internal/web/job/check_hash_storage.go deleted file mode 100644 index e51a6a8..0000000 --- a/internal/web/job/check_hash_storage.go +++ /dev/null @@ -1,22 +0,0 @@ -package job - -import "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" - -// CheckHashStorageJob periodically cleans up expired hash entries from the Telegram bot's hash storage. -type CheckHashStorageJob struct { - tgbotService tgbot.Tgbot -} - -// NewCheckHashStorageJob creates a new hash storage cleanup job instance. -func NewCheckHashStorageJob() *CheckHashStorageJob { - return new(CheckHashStorageJob) -} - -// Run removes expired hash entries from the Telegram bot's hash storage. -func (j *CheckHashStorageJob) Run() { - storage := j.tgbotService.GetHashStorage() - if storage == nil { - return - } - storage.RemoveExpiredHashes() -} diff --git a/internal/web/job/check_hash_storage_test.go b/internal/web/job/check_hash_storage_test.go deleted file mode 100644 index e324bcb..0000000 --- a/internal/web/job/check_hash_storage_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package job - -import "testing" - -func TestCheckHashStorageJob_RunWithoutPanicWhenStorageNil(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Fatalf("CheckHashStorageJob.Run panicked when storage is nil: %v", r) - } - }() - NewCheckHashStorageJob().Run() -} diff --git a/internal/web/job/stats_notify_job.go b/internal/web/job/stats_notify_job.go deleted file mode 100644 index 1d68131..0000000 --- a/internal/web/job/stats_notify_job.go +++ /dev/null @@ -1,33 +0,0 @@ -package job - -import ( - "github.com/mhsanaei/3x-ui/v3/internal/web/service" - "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" -) - -// LoginStatus represents the status of a login attempt. -type LoginStatus byte - -const ( - LoginSuccess LoginStatus = 1 // Successful login - LoginFail LoginStatus = 0 // Failed login attempt -) - -// StatsNotifyJob sends periodic statistics reports via Telegram bot. -type StatsNotifyJob struct { - xrayService service.XrayService - tgbotService tgbot.Tgbot -} - -// NewStatsNotifyJob creates a new statistics notification job instance. -func NewStatsNotifyJob() *StatsNotifyJob { - return new(StatsNotifyJob) -} - -// Run sends a statistics report via Telegram bot if Xray is running. -func (j *StatsNotifyJob) Run() { - if !j.xrayService.IsXrayRunning() { - return - } - j.tgbotService.SendReport() -} diff --git a/internal/web/job/xray_traffic_inform_test.go b/internal/web/job/xray_traffic_inform_test.go deleted file mode 100644 index b94b897..0000000 --- a/internal/web/job/xray_traffic_inform_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package job - -import ( - "errors" - "io" - "net" - "testing" - "time" - - "github.com/valyala/fasthttp" -) - -func stallingListener(t *testing.T) (addr string, release func()) { - t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - done := make(chan struct{}) - go func() { - defer close(done) - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(c net.Conn) { - <-done - _ = c.Close() - }(conn) - go func(c net.Conn) { _, _ = io.Copy(io.Discard, c) }(conn) - } - }() - return ln.Addr().String(), func() { - _ = ln.Close() - <-done - } -} - -func TestExternalInformClient_StalledReceiverTimesOut(t *testing.T) { - addr, release := stallingListener(t) - defer release() - - request := fasthttp.AcquireRequest() - defer fasthttp.ReleaseRequest(request) - request.Header.SetMethod("POST") - request.Header.SetContentType("application/json; charset=UTF-8") - request.SetBody([]byte(`{"clientTraffics":[],"inboundTraffics":[]}`)) - request.SetRequestURI("http://" + addr + "/inform") - request.Header.SetConnectionClose() - - response := fasthttp.AcquireResponse() - defer fasthttp.ReleaseResponse(response) - - start := time.Now() - err := externalInformClient.DoTimeout(request, response, externalInformTimeout) - elapsed := time.Since(start) - - if err == nil { - t.Fatal("a receiver that never answers must produce an error, not a completed request") - } - if !errors.Is(err, fasthttp.ErrTimeout) { - t.Errorf("want a timeout error, got %v", err) - } - if elapsed > externalInformTimeout+2*time.Second { - t.Errorf("call took %v, must be bounded by %v", elapsed, externalInformTimeout) - } -} - -func TestExternalInformClient_HasDeadlines(t *testing.T) { - if externalInformClient.ReadTimeout <= 0 || externalInformClient.WriteTimeout <= 0 { - t.Fatal("the traffic-notify client must carry read and write deadlines") - } - if externalInformTimeout >= 5*time.Second { - t.Errorf("the call budget (%v) must stay under the 5s traffic poll cadence", externalInformTimeout) - } -} - -func TestInformSkippedWhenNothingToReport(t *testing.T) { - j := &XrayTrafficJob{} - done := make(chan struct{}) - go func() { - defer close(done) - j.informTrafficToExternalAPI(nil, nil) - }() - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("an empty payload must return before touching settings or the network") - } -} diff --git a/internal/web/locale/locale.go b/internal/web/locale/locale.go index 95e854b..0ee642c 100644 --- a/internal/web/locale/locale.go +++ b/internal/web/locale/locale.go @@ -19,24 +19,17 @@ import ( var ( i18nBundle *i18n.Bundle LocalizerWeb *i18n.Localizer - LocalizerBot *i18n.Localizer ) // I18nType represents the type of interface for internationalization. type I18nType string const ( - Bot I18nType = "bot" // Bot interface type Web I18nType = "web" // Web interface type ) -// SettingService interface defines methods for accessing locale settings. -type SettingService interface { - GetTgLang() (string, error) -} - // InitLocalizer initializes the internationalization system with embedded translation files. -func InitLocalizer(i18nFS embed.FS, settingService SettingService) error { +func InitLocalizer(i18nFS embed.FS) error { // set default bundle to English i18nBundle = i18n.NewBundle(language.MustParse("en-US")) i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal) @@ -46,11 +39,6 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error { return err } - // setup bot locale - if err := initTGBotLocalizer(settingService); err != nil { - return err - } - return nil } @@ -71,14 +59,12 @@ func createTemplateData(params []string, separator ...string) map[string]any { } // I18n retrieves a localized message for the given key and type. -// It supports both bot and web contexts, with optional template parameters. +// It supports web contexts, with optional template parameters. // Returns the localized message or an empty string if localization fails. func I18n(i18nType I18nType, key string, params ...string) string { var localizer *i18n.Localizer switch i18nType { - case "bot": - localizer = LocalizerBot case "web": localizer = LocalizerWeb default: @@ -105,17 +91,6 @@ func I18n(i18nType I18nType, key string, params ...string) string { return msg } -// initTGBotLocalizer initializes the bot localizer with the configured language. -func initTGBotLocalizer(settingService SettingService) error { - botLang, err := settingService.GetTgLang() - if err != nil { - return err - } - - LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang) - return nil -} - // LocalizerMiddleware returns a Gin middleware that sets up localization for web requests. // It determines the user's language from cookies or Accept-Language header, // creates a localizer instance, and stores it in the Gin context for use in handlers. diff --git a/internal/web/service/inbound_tgbot_lookup_test.go b/internal/web/service/inbound_tgbot_lookup_test.go deleted file mode 100644 index 95cd056..0000000 --- a/internal/web/service/inbound_tgbot_lookup_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/xray" -) - -// TestGetClientTrafficTgBot_SettingsSerializationStyles guards against the -// prefilter regressing into a formatting-sensitive string match (#5805): the -// lookup must find clients whether inbounds.settings stores compact JSON -// ("tgId":N, as written by node sync/import) or indented JSON ("tgId": N, as -// written by the panel's MarshalIndent). -func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) { - dbDir := t.TempDir() - t.Setenv("XUI_DB_FOLDER", dbDir) - if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { - t.Fatalf("InitDB: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) - - db := database.GetDB() - - const tgId int64 = 123456789 - cases := []struct { - name string - settings string - email string - port int - }{ - {"compact", `{"clients":[{"id":"u1","email":"compact-user","tgId":123456789}]}`, "compact-user", 41001}, - {"spaced", `{"clients": [{"id": "u2", "email": "spaced-user", "tgId": 123456789}]}`, "spaced-user", 41002}, - } - for _, c := range cases { - inbound := &model.Inbound{UserId: 1, Tag: "tg-" + c.name, Enable: true, Port: c.port, Protocol: model.VLESS, Settings: c.settings} - if err := db.Create(inbound).Error; err != nil { - t.Fatalf("create %s inbound: %v", c.name, err) - } - if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: c.email, Enable: true, Up: 10, Down: 20}).Error; err != nil { - t.Fatalf("create %s client_traffics: %v", c.name, err) - } - } - - svc := InboundService{} - traffics, err := svc.GetClientTrafficTgBot(tgId) - if err != nil { - t.Fatalf("GetClientTrafficTgBot: %v", err) - } - got := make(map[string]bool, len(traffics)) - for _, tr := range traffics { - got[tr.Email] = true - } - if len(traffics) != 2 || !got["compact-user"] || !got["spaced-user"] { - t.Fatalf("expected traffic for compact-user and spaced-user, got %v", got) - } - - other, err := svc.GetClientTrafficTgBot(42) - if err != nil { - t.Fatalf("GetClientTrafficTgBot(42): %v", err) - } - if len(other) != 0 { - t.Fatalf("expected no traffic for unknown tgId, got %d rows", len(other)) - } -} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index aaa175c..2e3c9c1 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -7,7 +7,6 @@ import ( "fmt" "maps" "slices" - "strconv" "strings" "time" @@ -998,75 +997,6 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { return nil } -func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) { - db := database.GetDB() - - idQuery := fmt.Sprintf( - "SELECT DISTINCT inbounds.id %s WHERE %s = ?", - database.JSONClientsFromInbound(), - database.JSONFieldText("client.value", "tgId"), - ) - var inboundIds []int - if err := db.Raw(idQuery, strconv.FormatInt(tgId, 10)).Scan(&inboundIds).Error; err != nil { - logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err) - return nil, err - } - - var inbounds []*model.Inbound - if len(inboundIds) > 0 { - err := db.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err) - return nil, err - } - } - - var emails []string - for _, inbound := range inbounds { - clients, err := s.GetClients(inbound) - if err != nil { - logger.Errorf("Error retrieving clients for inbound %d: %v", inbound.Id, err) - continue - } - for _, client := range clients { - if client.TgID == tgId { - emails = append(emails, client.Email) - } - } - } - - // Chunked to stay under SQLite's bind-variable limit when a single Telegram - // account owns thousands of clients across inbounds. - uniqEmails := uniqueNonEmptyStrings(emails) - traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails)) - for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) { - var page []*xray.ClientTraffic - if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err) - return nil, err - } - traffics = append(traffics, page...) - } - if len(traffics) == 0 { - logger.Warning("No ClientTraffic records found for emails:", emails) - return nil, nil - } - - // Populate UUID and other client data for each traffic record - for i := range traffics { - if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { - traffics[i].Enable = client.Enable - traffics[i].UUID = client.ID - traffics[i].SubId = client.SubID - } - } - - return traffics, nil -} - // BumpClientsLastOnline sets client_traffics.last_online to now for the given // emails. Used in online-API mode for clients that hold a live connection but // moved no bytes this poll β€” the traffic path (addClientTraffic) only bumps diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 2ca9f8f..8cacbcb 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -70,16 +70,6 @@ var defaultValueMap = map[string]string{ "remarkTemplate": DefaultRemarkTemplate, "subShowIdentityOnAllLinks": "false", "timeLocation": "Local", - "tgBotEnable": "false", - "tgBotToken": "", - "tgBotProxy": "", - "tgBotAPIServer": "", - "tgBotChatId": "", - "tgRunTime": "@daily", - "tgBotBackup": "false", - "tgCpu": "80", - "tgMemory": "80", - "tgLang": "en-US", "subEnable": "true", "subJsonEnable": "false", "subJsonAutoDetect": "false", @@ -128,7 +118,6 @@ var defaultValueMap = map[string]string{ "devChannelEnable": "false", // Event bus β€” per-subscriber event filtering (empty = all disabled) - "tgEnabledEvents": "login.attempt,cpu.high", // Consecutive failed observatory probes before an outbound.down event fires "outboundDownThreshold": "3", @@ -228,14 +217,12 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { return nil, err } view := &entity.AllSettingView{AllSetting: *allSetting} - view.HasTgBotToken = secretConfigured(allSetting.TgBotToken) view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasNordSecret = secretConfigured(mustString(s.GetNord())) var apiTokenCount int64 if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil { view.HasApiToken = apiTokenCount > 0 } - view.TgBotToken = "" return view, nil } @@ -396,22 +383,6 @@ func (s *SettingService) GetWebDomain() (string, error) { return s.getString("webDomain") } -func (s *SettingService) GetTgBotToken() (string, error) { - return s.getString("tgBotToken") -} - -func (s *SettingService) SetTgBotToken(token string) error { - return s.setString("tgBotToken", token) -} - -func (s *SettingService) GetTgBotProxy() (string, error) { - return s.getString("tgBotProxy") -} - -func (s *SettingService) SetTgBotProxy(token string) error { - return s.setString("tgBotProxy", token) -} - // GetPanelOutbound returns the Xray outbound tag the panel's own outbound // requests (version checks, Telegram, subscription fetches) are routed through. func (s *SettingService) GetPanelOutbound() (string, error) { @@ -483,58 +454,6 @@ func (s *SettingService) NewProxiedHTTPClient(timeout time.Duration) *http.Clien return client } -func (s *SettingService) GetTgBotAPIServer() (string, error) { - return s.getString("tgBotAPIServer") -} - -func (s *SettingService) SetTgBotAPIServer(token string) error { - return s.setString("tgBotAPIServer", token) -} - -func (s *SettingService) GetTgBotChatId() (string, error) { - return s.getString("tgBotChatId") -} - -func (s *SettingService) SetTgBotChatId(chatIds string) error { - return s.setString("tgBotChatId", chatIds) -} - -func (s *SettingService) GetTgbotEnabled() (bool, error) { - return s.getBool("tgBotEnable") -} - -func (s *SettingService) SetTgbotEnabled(value bool) error { - return s.setBool("tgBotEnable", value) -} - -func (s *SettingService) GetTgbotRuntime() (string, error) { - return s.getString("tgRunTime") -} - -func (s *SettingService) SetTgbotRuntime(time string) error { - return s.setString("tgRunTime", time) -} - -func (s *SettingService) GetTgBotBackup() (bool, error) { - return s.getBool("tgBotBackup") -} - -func (s *SettingService) GetTgCpu() (int, error) { - return s.getInt("tgCpu") -} - -func (s *SettingService) GetTgMemory() (int, error) { - return s.getInt("tgMemory") -} - -func (s *SettingService) SetTgMemory(value int) error { - return s.setInt("tgMemory", value) -} - -func (s *SettingService) GetTgLang() (string, error) { - return s.getString("tgLang") -} - func (s *SettingService) GetPort() (int, error) { return s.getInt("webPort") } @@ -887,14 +806,6 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) { // Event bus β€” per-subscriber event filtering -func (s *SettingService) GetTgEnabledEvents() (string, error) { - return s.getString("tgEnabledEvents") -} - -func (s *SettingService) SetTgEnabledEvents(events string) error { - return s.setString("tgEnabledEvents", events) -} - // GetOutboundDownThreshold returns how many consecutive failed observatory // probes an outbound must accumulate before an outbound.down notification is // emitted. 1 preserves the legacy "notify on the first failed probe" behaviour. @@ -906,17 +817,7 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error { return s.setInt("outboundDownThreshold", value) } -// SecretClears marks redacted secrets the user explicitly emptied. Without a -// flag, a blank submitted secret means "unchanged" (the field is always served -// blank to the browser) and the stored value is preserved. -type SecretClears struct { - TgBotToken bool -} - -func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error { - if err := s.preserveRedactedSecrets(allSetting, clears); err != nil { - return err - } +func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { if err := validateSettingsURLs(allSetting); err != nil { return err } @@ -1005,25 +906,7 @@ func ValidateRegex(pattern string) error { return nil } -func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error { - if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" { - value, err := s.GetTgBotToken() - if err != nil { - return err - } - allSetting.TgBotToken = value - } - return nil -} - func validateSettingsURLs(allSetting *entity.AllSetting) error { - if allSetting.TgBotAPIServer != "" { - u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer) - if err != nil { - return common.NewError("telegram API server URL is invalid:", err) - } - allSetting.TgBotAPIServer = u - } // Support/profile links land in subscription headers and page data, where // client apps resolve a scheme-less value against the panel's own domain. // Non-http schemes (tg://, mailto:) are legitimate here, so only default @@ -1053,15 +936,6 @@ func validateRemoteRoutingURLSetting(name string, value *string) error { return nil } -func (s *SettingService) UpdateSecret(key string, value string) error { - switch key { - case "tgBotToken": - return s.saveSetting(key, strings.TrimSpace(value)) - default: - return common.NewError("secret key is not replaceable:", key) - } -} - func (s *SettingService) GetDefaultXrayConfig() (any, error) { var jsonData any err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData) @@ -1123,7 +997,6 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { "pageSize": func() (any, error) { return s.GetPageSize() }, "defaultCert": func() (any, error) { return s.GetCertFile() }, "defaultKey": func() (any, error) { return s.GetKeyFile() }, - "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, "subThemeDir": func() (any, error) { return s.GetSubThemeDir() }, "subEnable": func() (any, error) { return s.GetSubEnable() }, "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, @@ -1187,16 +1060,12 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { return result, nil } -var factoryDefaultSecretKeys = map[string]bool{ - "tgBotToken": true, -} - /* GetFactoryDefaults returns the shipped default value per setting, keyed by the AllSetting json field name. Unlike GetDefaultSettings (which reports current effective values), this is defaultValueMap projected through the AllSetting field set: only keys that exist as an AllSetting json tag are -returned, minus the credential fields in factoryDefaultSecretKeys. Keys +returned. Keys with no AllSetting field (secret, panelGuid, the node mTLS material, xrayTemplateConfig) are excluded structurally rather than by deny-list. */ @@ -1204,7 +1073,7 @@ func (s *SettingService) GetFactoryDefaults() map[string]string { result := make(map[string]string) for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) { key := field.Tag.Get("json") - if key == "" || factoryDefaultSecretKeys[key] { + if key == "" { continue } if value, ok := defaultValueMap[key]; ok { diff --git a/internal/web/service/setting_clash_test.go b/internal/web/service/setting_clash_test.go index 49d4514..ac701b4 100644 --- a/internal/web/service/setting_clash_test.go +++ b/internal/web/service/setting_clash_test.go @@ -79,7 +79,7 @@ func TestUpdateAllSettingPreservesEmptyUserAgentRegexes(t *testing.T) { settings.SubJsonUserAgentRegex = " " settings.SubClashUserAgentRegex = "" - if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { + if err := s.UpdateAllSetting(settings); err != nil { t.Fatal(err) } @@ -131,7 +131,7 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) { settings.SubClashEnableRouting = true settings.SubClashRules = "GEOIP,private,DIRECT" - if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { + if err := s.UpdateAllSetting(settings); err != nil { t.Fatal(err) } @@ -188,7 +188,7 @@ func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) { t.Fatal(err) } settings.SubClashUserAgentRegex = "[" - if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil { + if err := s.UpdateAllSetting(settings); err == nil { t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex") } } @@ -201,7 +201,7 @@ func TestUpdateAllSettingRejectsInvalidJsonUserAgentRegex(t *testing.T) { t.Fatal(err) } settings.SubJsonUserAgentRegex = "[" - if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil { + if err := s.UpdateAllSetting(settings); err == nil { t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex") } } diff --git a/internal/web/service/setting_factory_defaults_test.go b/internal/web/service/setting_factory_defaults_test.go index f503b14..1629fa8 100644 --- a/internal/web/service/setting_factory_defaults_test.go +++ b/internal/web/service/setting_factory_defaults_test.go @@ -54,9 +54,6 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) { "nodeMtlsClientKeyPem", "nodeMtlsClientCertSha256", "xrayTemplateConfig", - "tgBotToken", - "twoFactorToken", - "smtpPassword", } { t.Run(key, func(t *testing.T) { if _, ok := defaults[key]; ok { @@ -75,9 +72,6 @@ func TestGetFactoryDefaultsInvariant(t *testing.T) { if !tags[key] { t.Errorf("key %q is not an entity.AllSetting json tag", key) } - if factoryDefaultSecretKeys[key] { - t.Errorf("key %q is in the credential deny-list and must not be returned", key) - } }) } } diff --git a/internal/web/service/setting_security_test.go b/internal/web/service/setting_security_test.go index d65894c..7cd7a41 100644 --- a/internal/web/service/setting_security_test.go +++ b/internal/web/service/setting_security_test.go @@ -4,10 +4,7 @@ import ( "path/filepath" "testing" - "github.com/xlzd/gotp" - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func setupSettingTestDB(t *testing.T) { @@ -22,126 +19,6 @@ func setupSettingTestDB(t *testing.T) { }) } -func TestGetAllSettingViewRedactsSecrets(t *testing.T) { - setupSettingTestDB(t) - s := &SettingService{} - if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { - t.Fatal(err) - } - if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil { - t.Fatal(err) - } - - view, err := s.GetAllSettingView() - if err != nil { - t.Fatal(err) - } - if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" { - t.Fatalf("settings view leaked secrets: %#v", view) - } - if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword { - t.Fatalf("settings view did not report configured secret flags: %#v", view) - } -} - -func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { - setupSettingTestDB(t) - s := &SettingService{} - if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("twoFactorEnable", "true"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { - t.Fatal(err) - } - - view, err := s.GetAllSettingView() - if err != nil { - t.Fatal(err) - } - settings := &view.AllSetting - if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { - t.Fatal(err) - } - if got, _ := s.GetTgBotToken(); got != "telegram-secret" { - t.Fatalf("tg token = %q, want preserved secret", got) - } - if got, _ := s.GetLdapPassword(); got != "ldap-secret" { - t.Fatalf("ldap password = %q, want preserved secret", got) - } - if got, _ := s.GetTwoFactorToken(); got != "totp-secret" { - t.Fatalf("2fa token = %q, want preserved secret", got) - } - if got, _ := s.GetSmtpPassword(); got != "smtp-secret" { - t.Fatalf("smtp password = %q, want preserved secret", got) - } -} - -func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) { - setupSettingTestDB(t) - s := &SettingService{} - if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { - t.Fatal(err) - } - if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { - t.Fatal(err) - } - - view, err := s.GetAllSettingView() - if err != nil { - t.Fatal(err) - } - if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil { - t.Fatal(err) - } - if got, _ := s.GetSmtpPassword(); got != "" { - t.Fatalf("smtp password = %q, want cleared", got) - } - if got, _ := s.GetTgBotToken(); got != "telegram-secret" { - t.Fatalf("tg token = %q, unflagged secret must stay preserved", got) - } - if got, _ := s.GetLdapPassword(); got != "ldap-secret" { - t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got) - } - - view, err = s.GetAllSettingView() - if err != nil { - t.Fatal(err) - } - if view.HasSmtpPassword { - t.Fatal("hasSmtpPassword must report false after clearing") - } - if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil { - t.Fatal(err) - } - if got, _ := s.GetTgBotToken(); got != "" { - t.Fatalf("tg token = %q, want cleared", got) - } - if got, _ := s.GetLdapPassword(); got != "" { - t.Fatalf("ldap password = %q, want cleared", got) - } -} - func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil { t.Fatal("expected localhost URL to be blocked") @@ -151,25 +28,6 @@ func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { } } -func TestVerifyTwoFactorCode(t *testing.T) { - setupSettingTestDB(t) - s := &SettingService{} - if err := s.saveSetting("twoFactorEnable", "true"); err != nil { - t.Fatal(err) - } - const token = "JBSWY3DPEHPK3PXP" - if err := s.saveSetting("twoFactorToken", token); err != nil { - t.Fatal(err) - } - - if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil { - t.Fatalf("valid code rejected: %v", err) - } - if err := s.VerifyTwoFactorCode("000000"); err == nil { - t.Fatal("invalid code accepted") - } -} - func TestGetSecret_FallbacksOnEmptyDatabaseSetting(t *testing.T) { setupSettingTestDB(t) s := &SettingService{} diff --git a/internal/web/service/setting_sub_identity_test.go b/internal/web/service/setting_sub_identity_test.go index 004c991..1d15f5b 100644 --- a/internal/web/service/setting_sub_identity_test.go +++ b/internal/web/service/setting_sub_identity_test.go @@ -18,7 +18,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) { } settings.SubShowIdentityOnAllLinks = true - if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { + if err := s.UpdateAllSetting(settings); err != nil { t.Fatal(err) } if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got { @@ -30,7 +30,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) { t.Fatal(err) } settings.SubShowIdentityOnAllLinks = false - if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { + if err := s.UpdateAllSetting(settings); err != nil { t.Fatal(err) } if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got { diff --git a/internal/web/service/tgbot/numeric_input.go b/internal/web/service/tgbot/numeric_input.go deleted file mode 100644 index 37e90a9..0000000 --- a/internal/web/service/tgbot/numeric_input.go +++ /dev/null @@ -1,17 +0,0 @@ -package tgbot - -// updateNumericInput applies one number-pad key: -2 clears, -1 backspaces, and 0..9 append. -// Callers retain their own validation and keyboard labels. -func updateNumericInput(value, key int) int { - switch key { - case -2: - return 0 - case -1: - if value > 0 { - return value / 10 - } - return value - default: - return value*10 + key - } -} diff --git a/internal/web/service/tgbot/numeric_input_test.go b/internal/web/service/tgbot/numeric_input_test.go deleted file mode 100644 index 958e295..0000000 --- a/internal/web/service/tgbot/numeric_input_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package tgbot - -import ( - "go/ast" - "go/parser" - "go/token" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestUpdateNumericInput(t *testing.T) { - tests := []struct { - name string - value, key int - want int - }{ - {name: "append digit", value: 12, key: 3, want: 123}, - {name: "append zero", value: 12, key: 0, want: 120}, - {name: "backspace", value: 123, key: -1, want: 12}, - {name: "backspace zero", value: 0, key: -1, want: 0}, - {name: "clear", value: 123, key: -2, want: 0}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := updateNumericInput(tt.value, tt.key); got != tt.want { - t.Fatalf("updateNumericInput(%d, %d) = %d, want %d", tt.value, tt.key, got, tt.want) - } - }) - } -} - -func TestNumericInputTransitionIsUsedByEveryKeypad(t *testing.T) { - entries, err := os.ReadDir(".") - if err != nil { - t.Fatalf("read tgbot package: %v", err) - } - fset := token.NewFileSet() - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || filepath.Ext(name) != ".go" || name == "numeric_input.go" || filepath.Ext(strings.TrimSuffix(name, "_test.go")) != ".go" { - continue - } - parsed, err := parser.ParseFile(fset, name, nil, 0) - if err != nil { - t.Fatalf("parse %s: %v", name, err) - } - ast.Inspect(parsed, func(node ast.Node) bool { - switchStmt, ok := node.(*ast.SwitchStmt) - if !ok { - return true - } - hasClear, hasBackspace, hasDefault := false, false, false - for _, stmt := range switchStmt.Body.List { - clause := stmt.(*ast.CaseClause) - if clause.List == nil { - hasDefault = true - } - for _, expr := range clause.List { - hasClear = hasClear || numericKeyLiteral(expr, "2") - hasBackspace = hasBackspace || numericKeyLiteral(expr, "1") - } - } - if hasClear && hasBackspace && hasDefault { - position := fset.Position(switchStmt.Pos()) - t.Errorf("open-coded numeric keypad transition at %s; use updateNumericInput", position) - } - return true - }) - } -} - -func numericKeyLiteral(expr ast.Expr, magnitude string) bool { - unary, ok := expr.(*ast.UnaryExpr) - if !ok || unary.Op != token.SUB { - return false - } - literal, ok := unary.X.(*ast.BasicLit) - return ok && literal.Kind == token.INT && literal.Value == magnitude -} diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go deleted file mode 100644 index d7514c7..0000000 --- a/internal/web/service/tgbot/tgbot.go +++ /dev/null @@ -1,537 +0,0 @@ -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 - } - - // clients data to adding new client. receiver_inbound_IDs is the set of - // inbounds the new client will be attached to; receiver_inbound_ID mirrors - // the primary pick for the legacy attach-picker entry point. Per-protocol - // secrets (UUID, password, flow, method) are filled per-inbound on submit - // by ClientService.fillProtocolDefaults, so the bot only tracks universal - // client fields here. - receiver_inbound_ID int - receiver_inbound_IDs []int - client_Email string - client_LimitIP int - client_TotalGB int64 - client_ExpiryTime int64 - client_Enable bool - client_TgID string - client_SubID string - client_Comment string - client_Reset int -) - -// userStateStore guards the per-chat 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[int64]userStateEntry - lastPrune time.Time -} - -type userStateEntry struct { - state string - at time.Time -} - -var userStateMgr = &userStateStore{states: make(map[int64]userStateEntry)} - -func (s *userStateStore) set(chatID int64, state string) { - s.mu.Lock() - s.states[chatID] = userStateEntry{state: state, at: time.Now()} - s.mu.Unlock() -} - -func (s *userStateStore) get(chatID int64) (string, bool) { - s.mu.Lock() - defer s.mu.Unlock() - e, ok := s.states[chatID] - return e.state, ok -} - -func (s *userStateStore) clear(chatID int64) { - s.mu.Lock() - delete(s.states, chatID) - s.mu.Unlock() -} - -func (s *userStateStore) reset() { - s.mu.Lock() - s.states = make(map[int64]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")}, - }, - }) - 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 -} - -// 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() - - 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) -} diff --git a/internal/web/service/tgbot/tgbot_callback_auth_test.go b/internal/web/service/tgbot/tgbot_callback_auth_test.go deleted file mode 100644 index 6e8c78e..0000000 --- a/internal/web/service/tgbot/tgbot_callback_auth_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package tgbot - -import ( - "testing" - - "github.com/mymmrac/telego" -) - -func TestAnswerCallbackDeniesPrivilegedActionToNonAdmin(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Fatalf("a non-admin callback reached a privileged handler: %v", r) - } - }() - - tg := &Tgbot{} - for _, data := range []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "inbounds"} { - q := &telego.CallbackQuery{ - Data: data, - From: telego.User{ID: 999999}, - Message: &telego.Message{Chat: telego.Chat{ID: 1}}, - } - tg.answerCallback(q, false) - } -} - -func TestIsClientSelfCallback(t *testing.T) { - allowed := []string{"client_traffic", "client_sub_links", "client_qr_links", "client_sub_links alice@x"} - for _, d := range allowed { - if !isClientSelfCallback(d) { - t.Errorf("%q should be a per-user client callback", d) - } - } - denied := []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "get_banlogs", "get_usage"} - for _, d := range denied { - if isClientSelfCallback(d) { - t.Errorf("%q is an admin-only callback and must not be treated as per-user", d) - } - } -} diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go deleted file mode 100644 index ca9f483..0000000 --- a/internal/web/service/tgbot/tgbot_client.go +++ /dev/null @@ -1,782 +0,0 @@ -package tgbot - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "slices" - "strconv" - "strings" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "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/service" - "github.com/mhsanaei/3x-ui/v3/internal/xray" - - "github.com/mymmrac/telego" - tu "github.com/mymmrac/telego/telegoutil" - "github.com/skip2/go-qrcode" -) - -// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress -// client (email, attached inbounds, traffic limit, expiry, ip limit, comment) -// shown in the multi-inbound add flow. Per-protocol secrets (UUID, password, -// flow, method) are generated by fillProtocolDefaults on submit, so the bot -// never has to track them per inbound itself. -func (t *Tgbot) BuildClientDraftMessage() string { - now := time.Now().UnixMilli() - - expiry := "" - switch { - case client_ExpiryTime == 0: - expiry = t.I18nBot("tgbot.unlimited") - case client_ExpiryTime < 0: - expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days")) - default: - diff := client_ExpiryTime - now - if diff > 172800000 { - expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05") - } else { - expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours")) - } - } - - traffic := "♾️ Unlimited(Reset)" - if client_TotalGB > 0 { - traffic = common.FormatTraffic(client_TotalGB) - } - - ipLimit := "♾️ Unlimited(Reset)" - if client_LimitIP > 0 { - ipLimit = fmt.Sprint(client_LimitIP) - } - - attached := t.describeAttachedInbounds(receiver_inbound_IDs) - if attached == "" { - attached = "β€”" - } - - comment := client_Comment - if comment == "" { - comment = "β€”" - } - - tgID := client_TgID - if tgID == "" { - tgID = "β€”" - } - - var b strings.Builder - b.WriteString("πŸ“ *New client draft*\r\n") - fmt.Fprintf(&b, "πŸ“§ Email: `%s`\r\n", client_Email) - fmt.Fprintf(&b, "πŸ”— Attached: %s\r\n", attached) - fmt.Fprintf(&b, "πŸ“Š Traffic: %s\r\n", traffic) - fmt.Fprintf(&b, "πŸ“… Expire: %s\r\n", expiry) - fmt.Fprintf(&b, "πŸ”’ IP limit: %s\r\n", ipLimit) - fmt.Fprintf(&b, "πŸ‘€ TG user: %s\r\n", tgID) - fmt.Fprintf(&b, "πŸ’¬ Comment: %s\r\n", comment) - return b.String() -} - -// describeAttachedInbounds returns a short "remark1, remark2" list for the given -// inbound ids, falling back to "#id" when an inbound can't be loaded. -func (t *Tgbot) describeAttachedInbounds(ids []int) string { - if len(ids) == 0 { - return "" - } - parts := make([]string, 0, len(ids)) - for _, id := range ids { - ib, err := t.inboundService.GetInbound(id) - if err != nil || ib == nil { - parts = append(parts, fmt.Sprintf("#%d", id)) - continue - } - label := ib.Remark - if label == "" { - label = fmt.Sprintf("#%d", id) - } - parts = append(parts, label) - } - return strings.Join(parts, ", ") -} - -// SubmitAddClient sends the in-progress client to ClientService.Create with -// the full set of attached inbound ids. Per-inbound fillProtocolDefaults on -// the panel generates UUID/password/auth per protocol, so the bot only -// supplies the universal fields it actually collected. -func (t *Tgbot) SubmitAddClient() (bool, error) { - inboundIDs := receiver_inbound_IDs - if len(inboundIDs) == 0 && receiver_inbound_ID > 0 { - inboundIDs = []int{receiver_inbound_ID} - } - if len(inboundIDs) == 0 { - return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64) - client := model.Client{ - Email: client_Email, - Enable: client_Enable, - LimitIP: client_LimitIP, - TotalGB: client_TotalGB, - ExpiryTime: client_ExpiryTime, - SubID: client_SubID, - Comment: client_Comment, - Reset: client_Reset, - TgID: tgIDInt, - } - - return t.clientService.Create(&t.inboundService, &service.ClientCreatePayload{ - Client: client, - InboundIds: inboundIDs, - }) -} - -// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email -func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) { - // Resolve subId from client email - traffic, client, err := t.inboundService.GetClientByEmail(email) - _ = traffic - if err != nil || client == nil { - return "", "", errors.New("client not found") - } - - // Gather settings to construct absolute URLs - subURI, _ := t.settingService.GetSubURI() - subJsonURI, _ := t.settingService.GetSubJsonURI() - subDomain, _ := t.settingService.GetSubDomain() - subPort, _ := t.settingService.GetSubPort() - subPath, _ := t.settingService.GetSubPath() - subJsonPath, _ := t.settingService.GetSubJsonPath() - subJsonEnable, _ := t.settingService.GetSubJsonEnable() - subKeyFile, _ := t.settingService.GetSubKeyFile() - subCertFile, _ := t.settingService.GetSubCertFile() - - tls := (subKeyFile != "" && subCertFile != "") - scheme := "http" - if tls { - scheme = "https" - } - - // Fallbacks - if subDomain == "" { - // try panel domain, otherwise OS hostname - if d, err := t.settingService.GetWebDomain(); err == nil && d != "" { - subDomain = d - } else if hostname != "" { - subDomain = hostname - } else { - subDomain = "localhost" - } - } - - host := subDomain - if (subPort == 443 && tls) || (subPort == 80 && !tls) { - // standard ports: no port in host - } else { - host = fmt.Sprintf("%s:%d", subDomain, subPort) - } - - // Ensure paths - if !strings.HasPrefix(subPath, "/") { - subPath = "/" + subPath - } - if !strings.HasSuffix(subPath, "/") { - subPath = subPath + "/" - } - if !strings.HasPrefix(subJsonPath, "/") { - subJsonPath = "/" + subJsonPath - } - if !strings.HasSuffix(subJsonPath, "/") { - subJsonPath = subJsonPath + "/" - } - - var subURL string - var subJsonURL string - - // If pre-configured URIs are available, use them directly - if subURI != "" { - if !strings.HasSuffix(subURI, "/") { - subURI = subURI + "/" - } - subURL = fmt.Sprintf("%s%s", subURI, client.SubID) - } else { - subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID) - } - - if subJsonURI != "" { - if !strings.HasSuffix(subJsonURI, "/") { - subJsonURI = subJsonURI + "/" - } - subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID) - } else { - subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID) - } - - if !subJsonEnable { - subJsonURL = "" - } - return subURL, subJsonURL, nil -} - -// sendClientSubLinks sends the subscription links for the client to the chat. -func (t *Tgbot) sendClientSubLinks(chatId int64, email string) { - subURL, subJsonURL, err := t.buildSubscriptionURLs(email) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - msg := "Subscription URL:\r\n" + subURL + "" - if subJsonURL != "" { - msg += "\r\n\r\nJSON URL:\r\n" + subJsonURL + "" - } - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)), - ), - ) - t.SendMsgToTgbot(chatId, msg, inlineKeyboard) -} - -// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user -func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) { - // Build the HTML sub page URL; we'll call it with header Accept to get raw content - subURL, _, err := t.buildSubscriptionURLs(email) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - - // Try to fetch raw subscription links. Prefer plain text response. - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - // Force plain text to avoid HTML page; controller respects Accept header - req.Header.Set("Accept", "text/plain, */*;q=0.1") - - // Use optimized client with connection pooling - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - req = req.WithContext(ctx) - - resp, err := optimizedHTTPClient.Do(req) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - - // If service is configured to encode (Base64), decode it - encoded, _ := t.settingService.GetSubEncrypt() - var content string - if encoded { - decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes)) - if err != nil { - // fallback to raw text - content = string(bodyBytes) - } else { - content = string(decoded) - } - } else { - content = string(bodyBytes) - } - - // Normalize line endings and trim - lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") - var cleaned []string - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - cleaned = append(cleaned, l) - } - } - if len(cleaned) == 0 { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult")) - return - } - - // Send in chunks to respect message length; use monospace formatting - const maxPerMessage = 50 - for i := 0; i < len(cleaned); i += maxPerMessage { - j := min(i+maxPerMessage, len(cleaned)) - chunk := cleaned[i:j] - var msg strings.Builder - msg.WriteString(t.I18nBot("subscription.individualLinks")) - msg.WriteString(":\r\n") - for _, link := range chunk { - // wrap each link in - msg.WriteString("") - msg.WriteString(link) - msg.WriteString("\r\n") - } - t.SendMsgToTgbot(chatId, msg.String()) - } -} - -// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them -func (t *Tgbot) sendClientQRLinks(chatId int64, email string) { - subURL, subJsonURL, err := t.buildSubscriptionURLs(email) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - - // Helper to create QR PNG bytes from content - createQR := func(content string, size int) ([]byte, error) { - if size <= 0 { - size = 256 - } - return qrcode.Encode(content, qrcode.Medium, size) - } - - // Inform user - t.SendMsgToTgbot(chatId, "QRCode for client "+email+":") - - // Send sub URL QR (filename: sub.png) - if png, err := createQR(subURL, 320); err == nil { - document := tu.Document( - tu.ID(chatId), - tu.FileFromBytes(png, "sub.png"), - ) - _, _ = bot.SendDocument(context.Background(), document) - } else { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - } - - // Send JSON URL QR (filename: subjson.png) when available - if subJsonURL != "" { - if png, err := createQR(subJsonURL, 320); err == nil { - document := tu.Document( - tu.ID(chatId), - tu.FileFromBytes(png, "subjson.png"), - ) - _, _ = bot.SendDocument(context.Background(), document) - } else { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - } - } - - // Also generate a few individual links' QRs (first up to 5) - subPageURL := subURL - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil) - if err == nil { - req.Header.Set("Accept", "text/plain, */*;q=0.1") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - req = req.WithContext(ctx) - if resp, err := optimizedHTTPClient.Do(req); err == nil { - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - encoded, _ := t.settingService.GetSubEncrypt() - var content string - if encoded { - if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil { - content = string(dec) - } else { - content = string(body) - } - } else { - content = string(body) - } - lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") - var cleaned []string - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - cleaned = append(cleaned, l) - } - } - if len(cleaned) > 0 { - max := min(len(cleaned), 5) - for i := range max { - if png, err := createQR(cleaned[i], 320); err == nil { - // Use the email as filename for individual link QR - filename := email + ".png" - document := tu.Document( - tu.ID(chatId), - tu.FileFromBytes(png, filename), - ) - _, _ = bot.SendDocument(context.Background(), document) - // Reduced delay for better performance - if i < max-1 { // Only delay between documents, not after the last one - time.Sleep(50 * time.Millisecond) - } - } - } - } - } - } -} - -// clientInfoMsg formats client information message based on traffic and flags. -func (t *Tgbot) clientInfoMsg( - traffic *xray.ClientTraffic, - printEnabled bool, - printOnline bool, - printActive bool, - printDate bool, - printTraffic bool, - printRefreshed bool, -) string { - now := time.Now().Unix() - expiryTime := "" - flag := false - diff := traffic.ExpiryTime/1000 - now - if traffic.ExpiryTime == 0 { - expiryTime = t.I18nBot("tgbot.unlimited") - } else if diff > 172800 || !traffic.Enable { - expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") - if diff > 0 { - days := diff / 86400 - hours := (diff % 86400) / 3600 - minutes := (diff % 3600) / 60 - remainingTime := "" - if days > 0 { - remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days")) - } - if hours > 0 { - remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours")) - } - if minutes > 0 { - remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes")) - } - expiryTime += fmt.Sprintf(" (%s)", remainingTime) - } - } else if traffic.ExpiryTime < 0 { - expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days")) - flag = true - } else { - expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours")) - flag = true - } - - total := "" - if traffic.Total == 0 { - total = t.I18nBot("tgbot.unlimited") - } else { - total = common.FormatTraffic(traffic.Total) - } - - enabled := "" - isEnabled, err := t.clientService.CheckIsEnabledByEmail(&t.inboundService, traffic.Email) - if err != nil { - logger.Warning(err) - enabled = t.I18nBot("tgbot.wentWrong") - } else if isEnabled { - enabled = t.I18nBot("tgbot.messages.yes") - } else { - enabled = t.I18nBot("tgbot.messages.no") - } - - active := "" - if traffic.Enable { - active = t.I18nBot("tgbot.messages.yes") - } else { - active = t.I18nBot("tgbot.messages.no") - } - - status := t.I18nBot("tgbot.offline") - isOnline := false - if process := service.XrayProcess(); process != nil && process.IsRunning() { - if slices.Contains(process.GetOnlineClients(), traffic.Email) { - status = t.I18nBot("tgbot.online") - isOnline = true - } - } - - output := "" - output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email) - if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 { - output += fmt.Sprintf("πŸ”— Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds)) - } - if printEnabled { - output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled) - } - if printOnline { - output += t.I18nBot("tgbot.messages.online", "Status=="+status) - if !isOnline && traffic.LastOnline > 0 { - output += t.I18nBot("tgbot.messages.lastOnline", "Time=="+time.UnixMilli(traffic.LastOnline).Format("2006-01-02 15:04:05")) - } - } - if printActive { - output += t.I18nBot("tgbot.messages.active", "Enable=="+active) - } - if printDate { - if flag { - output += t.I18nBot("tgbot.messages.expireIn", "Time=="+expiryTime) - } else { - output += t.I18nBot("tgbot.messages.expire", "Time=="+expiryTime) - } - } - if printTraffic { - output += t.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(traffic.Up)) - output += t.I18nBot("tgbot.messages.download", "Download=="+common.FormatTraffic(traffic.Down)) - output += t.I18nBot("tgbot.messages.total", "UpDown=="+common.FormatTraffic((traffic.Up+traffic.Down)), "Total=="+total) - } - if printRefreshed { - output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) - } - - return output -} - -// getClientUsage retrieves and sends client usage information to the chat. -func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) { - traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) - if err != nil { - logger.Warning(err) - msg := t.I18nBot("tgbot.wentWrong") - t.SendMsgToTgbot(chatId, msg) - return - } - - if len(traffics) == 0 { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) - return - } - - output := "" - - if len(traffics) > 0 { - if len(email) > 0 { - for _, traffic := range traffics { - if traffic.Email == email[0] { - output := t.clientInfoMsg(traffic, true, true, true, true, true, true) - t.SendMsgToTgbot(chatId, output) - return - } - } - msg := t.I18nBot("tgbot.noResult") - t.SendMsgToTgbot(chatId, msg) - return - } else { - for _, traffic := range traffics { - output += t.clientInfoMsg(traffic, true, true, true, true, true, false) - output += "\r\n" - } - } - } - - output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) - t.SendMsgToTgbot(chatId, output) - output = t.I18nBot("tgbot.commands.pleaseChoose") - t.SendAnswer(chatId, output, false) -} - -// searchClientIps searches and sends client IP addresses for the given email. -func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) { - ips, err := t.inboundService.GetInboundClientIps(email) - if err != nil || len(ips) == 0 { - ips = t.I18nBot("tgbot.noIpRecord") - } - - formattedIps := ips - if err == nil && len(ips) > 0 { - type ipWithTimestamp struct { - IP string `json:"ip"` - Timestamp int64 `json:"timestamp"` - } - - var ipsWithTime []ipWithTimestamp - if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 { - lines := make([]string, 0, len(ipsWithTime)) - for _, item := range ipsWithTime { - if item.IP == "" { - continue - } - if item.Timestamp > 0 { - ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05") - lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts)) - continue - } - lines = append(lines, item.IP) - } - if len(lines) > 0 { - formattedIps = strings.Join(lines, "\n") - } - } else { - var oldIps []string - if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 { - formattedIps = strings.Join(oldIps, "\n") - } - } - } - - output := "" - output += t.I18nBot("tgbot.messages.email", "Email=="+email) - output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps) - output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) - - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)), - ), - ) - - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, output, inlineKeyboard) - } -} - -// clientTelegramUserInfo retrieves and sends Telegram user info for the client. -func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) { - traffic, client, err := t.inboundService.GetClientByEmail(email) - if err != nil { - logger.Warning(err) - msg := t.I18nBot("tgbot.wentWrong") - t.SendMsgToTgbot(chatId, msg) - return - } - if client == nil { - msg := t.I18nBot("tgbot.noResult") - t.SendMsgToTgbot(chatId, msg) - return - } - tgId := "None" - if client.TgID != 0 { - tgId = strconv.FormatInt(client.TgID, 10) - } - - output := "" - output += t.I18nBot("tgbot.messages.email", "Email=="+email) - output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId) - output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) - - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)), - ), - ) - - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, output, inlineKeyboard) - requestUser := telego.KeyboardButtonRequestUsers{ - RequestID: int32(traffic.Id), - UserIsBot: new(bool), - } - keyboard := tu.Keyboard( - tu.KeyboardRow( - tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser), - ), - tu.KeyboardRow( - tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")), - ), - ).WithIsPersistent().WithResizeKeyboard() - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard) - } -} - -// searchClient searches for a client by email and sends the information. -func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { - traffic, err := t.inboundService.GetClientTrafficByEmail(email) - if err != nil { - logger.Warning(err) - msg := t.I18nBot("tgbot.wentWrong") - t.SendMsgToTgbot(chatId, msg) - return - } - if traffic == nil { - msg := t.I18nBot("tgbot.noResult") - t.SendMsgToTgbot(chatId, msg) - return - } - - output := t.clientInfoMsg(traffic, true, true, true, true, true, true) - - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)), - ), - ) - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, output, inlineKeyboard) - } -} - -// getCommonClientButtons returns the shared inline keyboard rows for the -// client-first multi-inbound add flow. Per-protocol secrets (UUID, password, -// flow, method) are generated by fillProtocolDefaults on submit, so the bot -// only exposes the universal client fields here. -func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton { - attachLabel := fmt.Sprintf("βž• Attach inbound (%d)", len(receiver_inbound_IDs)) - return [][]telego.InlineKeyboardButton{ - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"), - ), - } -} - -// addClient renders the draft message + shared client-first keyboard. -func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { - inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...) - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, msg, inlineKeyboard) - } -} diff --git a/internal/web/service/tgbot/tgbot_delete_after_test.go b/internal/web/service/tgbot/tgbot_delete_after_test.go deleted file mode 100644 index 4a6ca73..0000000 --- a/internal/web/service/tgbot/tgbot_delete_after_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package tgbot - -import "testing" - -func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) { - userStateMgr.reset() - t.Cleanup(userStateMgr.reset) - - const chatID = int64(4242) - userStateMgr.set(chatID, "awaiting_comment") - - tg := &Tgbot{} - tg.deleteMessageAfterDelay(chatID, 1, 0) - - if st, ok := userStateMgr.get(chatID); !ok || st != "awaiting_comment" { - t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment") - } -} diff --git a/internal/web/service/tgbot/tgbot_event.go b/internal/web/service/tgbot/tgbot_event.go deleted file mode 100644 index 822dacf..0000000 --- a/internal/web/service/tgbot/tgbot_event.go +++ /dev/null @@ -1,162 +0,0 @@ -package tgbot - -import ( - "fmt" - "os" - "strconv" - "strings" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/eventbus" -) - -var cachedHostname string - -func getHostname() string { - if cachedHostname != "" { - return cachedHostname - } - h, err := os.Hostname() - if err != nil { - cachedHostname = "unknown" - } else { - cachedHostname = h - } - return cachedHostname -} - -var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute) - -// HandleEvent is the eventbus subscriber callback. It formats incoming events -// as Telegram messages and sends them to all admin chats. -func (t *Tgbot) HandleEvent(e eventbus.Event) { - if !t.isEventEnabled(e.Type) { - return - } - if e.Type != eventbus.EventLoginAttempt { - if !tgEventLimiter.Allow(e.Type, e.Source) { - return - } - } - msg := t.formatEventMessage(e) - if msg != "" { - t.SendMsgToTgbotAdmins(msg) - } -} - -func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool { - events, err := t.settingService.GetTgEnabledEvents() - if err != nil || events == "" { - return false - } - for e := range strings.SplitSeq(events, ",") { - if strings.TrimSpace(e) == string(eventType) { - return true - } - } - return false -} - -func (t *Tgbot) formatEventMessage(e eventbus.Event) string { - host := getHostname() - header := fmt.Sprintf("πŸ“‘ %s\n", host) - - switch e.Type { - case eventbus.EventOutboundDown: - msg := header + t.I18nBot("tgbot.messages.eventOutboundDown", - "Tag=="+e.Source) - if data, ok := e.Data.(*eventbus.OutboundHealthData); ok { - if data.Error != "" { - msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", - "Error=="+data.Error) - } - if data.Delay > 0 { - msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", - "Delay=="+fmt.Sprintf("%d", data.Delay)) - } - } - return msg - - case eventbus.EventOutboundUp: - msg := header + t.I18nBot("tgbot.messages.eventOutboundUp", - "Tag=="+e.Source) - if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 { - msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", - "Delay=="+fmt.Sprintf("%d", data.Delay)) - } - return msg - - case eventbus.EventXrayCrash: - errStr := "" - if e.Data != nil { - errStr = fmt.Sprint(e.Data) - } - msg := header + "πŸ”₯ " + t.I18nBot("tgbot.messages.eventXrayCrash") - if errStr != "" { - msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr) - } - return msg - - case eventbus.EventNodeDown: - msg := header + "πŸ”΄ " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source) - if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" { - msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError) - } - return msg - - case eventbus.EventNodeUp: - msg := header + "🟒 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source) - if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 { - msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs)) - } - return msg - - case eventbus.EventCPUHigh: - if data, ok := e.Data.(*eventbus.SystemMetricData); ok { - tgCpu, err := t.settingService.GetTgCpu() - if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) { - return "" - } - return header + "πŸ”΄ " + t.I18nBot("tgbot.messages.cpuThreshold", - "Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64), - "Threshold=="+strconv.Itoa(tgCpu)) - } - return "" - - case eventbus.EventMemoryHigh: - if data, ok := e.Data.(*eventbus.SystemMetricData); ok { - tgMemory, err := t.settingService.GetTgMemory() - if err != nil || tgMemory <= 0 || data.Percent <= float64(tgMemory) { - return "" - } - return header + "πŸ”΄ " + t.I18nBot("tgbot.messages.memoryThreshold", - "Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64), - "Threshold=="+strconv.Itoa(tgMemory)) - } - return "" - - case eventbus.EventLoginAttempt: - if data, ok := e.Data.(*eventbus.LoginEventData); ok { - if data.Status == "success" { - msg := t.I18nBot("tgbot.messages.loginSuccess") - msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) - msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) - msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) - msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) - return msg - } - msg := t.I18nBot("tgbot.messages.loginFailed") - msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) - if data.Reason != "" { - msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason) - } - msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) - msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) - msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) - return msg - } - return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source) - } - - return "" -} diff --git a/internal/web/service/tgbot/tgbot_inbound.go b/internal/web/service/tgbot/tgbot_inbound.go deleted file mode 100644 index 12b1caf..0000000 --- a/internal/web/service/tgbot/tgbot_inbound.go +++ /dev/null @@ -1,306 +0,0 @@ -package tgbot - -import ( - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "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" -) - -// getInboundUsages retrieves and formats inbound usage information. -func (t *Tgbot) getInboundUsages() string { - var info strings.Builder - inbounds, err := t.inboundService.GetAllInbounds() - if err != nil { - logger.Warning("GetAllInbounds run failed:", err) - info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed")) - return info.String() - } - for _, inbound := range inbounds { - info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)) - info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))) - info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))) - - clients, listErr := t.clientService.ListForInbound(nil, inbound.Id) - if listErr == nil { - fmt.Fprintf(&info, "πŸ‘₯ Clients: %d\r\n", len(clients)) - } - - if inbound.ExpiryTime == 0 { - info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))) - } else { - info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))) - } - info.WriteString("\r\n") - } - return info.String() -} - -// getInbounds creates an inline keyboard with all inbounds. -func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) { - inbounds, err := t.inboundService.GetAllInbounds() - if err != nil { - logger.Warning("GetAllInbounds run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - if len(inbounds) == 0 { - logger.Warning("No inbounds found") - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - var buttons []telego.InlineKeyboardButton - for _, inbound := range inbounds { - status := "❌" - if inbound.Enable { - status = "βœ…" - } - callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id)) - buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData)) - } - - cols := 1 - if len(buttons) >= 6 { - cols = 2 - } - - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - return keyboard, nil -} - -// getInboundsFor builds an inline keyboard of inbounds for a custom next action. -func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) { - inbounds, err := t.inboundService.GetAllInbounds() - if err != nil { - logger.Warning("GetAllInbounds run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - if len(inbounds) == 0 { - logger.Warning("No inbounds found") - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - var buttons []telego.InlineKeyboardButton - for _, inbound := range inbounds { - status := "❌" - if inbound.Enable { - status = "βœ…" - } - callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id)) - buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData)) - } - - cols := 1 - if len(buttons) >= 6 { - cols = 2 - } - - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - return keyboard, nil -} - -// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email -func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) { - inbound, err := t.inboundService.GetInbound(inboundID) - if err != nil { - logger.Warning("getInboundClientsFor run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - clients, err := t.inboundService.GetClients(inbound) - var buttons []telego.InlineKeyboardButton - - if err != nil { - logger.Warning("GetInboundClients run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } else { - if len(clients) > 0 { - for _, client := range clients { - buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email))) - } - } else { - return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed")) - } - } - cols := 0 - if len(buttons) < 6 { - cols = 3 - } else { - cols = 2 - } - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - - return keyboard, nil -} - -// getInboundsAddClient creates an inline keyboard for adding clients to inbounds. -func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { - inbounds, err := t.inboundService.GetAllInbounds() - if err != nil { - logger.Warning("GetAllInbounds run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - if len(inbounds) == 0 { - logger.Warning("No inbounds found") - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - excludedProtocols := map[model.Protocol]bool{ - model.Tunnel: true, - model.Mixed: true, - model.WireGuard: true, - model.AmneziaWG: true, - model.HTTP: true, - } - - var buttons []telego.InlineKeyboardButton - for _, inbound := range inbounds { - if excludedProtocols[inbound.Protocol] { - continue - } - - status := "❌" - if inbound.Enable { - status = "βœ…" - } - callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id)) - buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData)) - } - - cols := 1 - if len(buttons) >= 6 { - cols = 2 - } - - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - return keyboard, nil -} - -// getInboundsAttachPicker builds a toggle picker over multi-client inbounds -// for the "attach more inbounds to the new client" step. Each row shows the -// current selection state for the inbound; tapping fires -// add_client_toggle_attach which flips it and re-renders. A final -// "Done" button (add_client_attach_done) returns to the field-edit screen. -func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) { - inbounds, err := t.inboundService.GetAllInbounds() - if err != nil { - logger.Warning("GetAllInbounds run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - if len(inbounds) == 0 { - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - excludedProtocols := map[model.Protocol]bool{ - model.Tunnel: true, - model.Mixed: true, - model.WireGuard: true, - model.AmneziaWG: true, - model.HTTP: true, - } - selected := make(map[int]bool, len(receiver_inbound_IDs)) - for _, id := range receiver_inbound_IDs { - selected[id] = true - } - var buttons []telego.InlineKeyboardButton - for _, ib := range inbounds { - if excludedProtocols[ib.Protocol] { - continue - } - mark := "☐" - if selected[ib.Id] { - mark = "βœ…" - } - label := fmt.Sprintf("%s %s (%s)", mark, ib.Remark, ib.Protocol) - callback := t.encodeQuery(fmt.Sprintf("add_client_toggle_attach %d", ib.Id)) - buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(callback)) - } - cols := 1 - if len(buttons) >= 6 { - cols = 2 - } - rows := tu.InlineKeyboardCols(cols, buttons...) - rows = append(rows, tu.InlineKeyboardRow( - tu.InlineKeyboardButton("βœ… Done").WithCallbackData(t.encodeQuery("add_client_attach_done")), - )) - return tu.InlineKeyboardGrid(rows), nil -} - -// getInboundClients creates an inline keyboard with clients of a specific inbound. -func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) { - inbound, err := t.inboundService.GetInbound(id) - if err != nil { - logger.Warning("getIboundClients run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - clients, err := t.inboundService.GetClients(inbound) - var buttons []telego.InlineKeyboardButton - - if err != nil { - logger.Warning("GetInboundClients run failed:", err) - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } else { - if len(clients) > 0 { - for _, client := range clients { - buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email))) - } - } else { - return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed")) - } - } - cols := 0 - if len(buttons) < 6 { - cols = 3 - } else { - cols = 2 - } - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - - return keyboard, nil -} - -// searchInbound searches for inbounds by remark and sends the results. -func (t *Tgbot) searchInbound(chatId int64, remark string) { - inbounds, err := t.inboundService.SearchInbounds(remark) - if err != nil { - logger.Warning(err) - msg := t.I18nBot("tgbot.wentWrong") - t.SendMsgToTgbot(chatId, msg) - return - } - if len(inbounds) == 0 { - msg := t.I18nBot("tgbot.noInbounds") - t.SendMsgToTgbot(chatId, msg) - return - } - - for _, inbound := range inbounds { - info := "" - info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark) - info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)) - info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)) - - if inbound.ExpiryTime == 0 { - info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")) - } else { - info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")) - } - t.SendMsgToTgbot(chatId, info) - - if len(inbound.ClientStats) > 0 { - var output strings.Builder - for _, traffic := range inbound.ClientStats { - output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true)) - } - t.SendMsgToTgbot(chatId, output.String()) - } - } -} diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go deleted file mode 100644 index b323733..0000000 --- a/internal/web/service/tgbot/tgbot_report.go +++ /dev/null @@ -1,509 +0,0 @@ -package tgbot - -import ( - "context" - "fmt" - "net" - "os" - "strconv" - "strings" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/config" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "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/service" - "github.com/mhsanaei/3x-ui/v3/internal/xray" - - "github.com/mymmrac/telego" - tu "github.com/mymmrac/telego/telegoutil" -) - -// SendReport sends a periodic report to admin chats. -func (t *Tgbot) SendReport() { - runTime, err := t.settingService.GetTgbotRuntime() - if err == nil && len(runTime) > 0 { - msg := "" - msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime) - msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05")) - t.SendMsgToTgbotAdmins(msg) - } - - info := t.sendServerUsage() - t.SendMsgToTgbotAdmins(info) - - t.sendExhaustedToAdmins() - t.notifyExhausted() - - backupEnable, err := t.settingService.GetTgBotBackup() - if err == nil && backupEnable { - t.SendBackupToAdmins() - } -} - -// SendBackupToAdmins sends a database backup to admin chats. -func (t *Tgbot) SendBackupToAdmins() { - if !t.IsRunning() { - return - } - dbData, err := t.serverService.GetDb() - if err != nil { - logger.Error("Error in getting db backup: ", err) - } - dbFilename := t.serverService.BackupFilename("") - for i, adminId := range adminIds { - t.sendBackupData(adminId, dbData, dbFilename) - // Add delay between sends to avoid Telegram rate limits - if i < len(adminIds)-1 { - time.Sleep(1 * time.Second) - } - } -} - -// sendExhaustedToAdmins sends notifications about exhausted clients to admins. -func (t *Tgbot) sendExhaustedToAdmins() { - if !t.IsRunning() { - return - } - for _, adminId := range adminIds { - t.getExhausted(adminId) - } -} - -// getServerUsage retrieves and formats server usage information. -func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string { - info := t.prepareServerUsageInfo() - - keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh")))) - - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], info, keyboard) - } else { - t.SendMsgToTgbot(chatId, info, keyboard) - } - - return info -} - -// Send server usage without an inline keyboard -func (t *Tgbot) sendServerUsage() string { - info := t.prepareServerUsageInfo() - return info -} - -// prepareServerUsageInfo prepares the server usage information string. -func (t *Tgbot) prepareServerUsageInfo() string { - // Check if we have cached data first - if cachedStats, found := t.getCachedServerStats(); found { - return cachedStats - } - - info, ipv4, ipv6 := "", "", "" - - // get latest status of server with caching - if cachedStatus, found := t.getCachedStatus(); found { - t.lastStatus = cachedStatus - } else { - t.lastStatus = t.serverService.GetStatus(t.lastStatus) - t.setCachedStatus(t.lastStatus) - } - var onlines []string - if process := service.XrayProcess(); process != nil { - onlines = process.GetOnlineClients() - } - - info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) - info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion()) - info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version)) - - // get ip address - netInterfaces, err := net.Interfaces() - if err != nil { - logger.Error("net.Interfaces failed, err: ", err.Error()) - info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown")) - info += "\r\n" - } else { - for i := range netInterfaces { - if (netInterfaces[i].Flags & net.FlagUp) != 0 { - addrs, _ := netInterfaces[i].Addrs() - - for _, address := range addrs { - if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { - if ipnet.IP.To4() != nil { - ipv4 += ipnet.IP.String() + " " - } else if ipnet.IP.To16() != nil && !ipnet.IP.IsLinkLocalUnicast() { - ipv6 += ipnet.IP.String() + " " - } - } - } - } - } - - info += t.I18nBot("tgbot.messages.ipv4", "IPv4=="+ipv4) - info += t.I18nBot("tgbot.messages.ipv6", "IPv6=="+ipv6) - } - - info += t.I18nBot("tgbot.messages.serverUpTime", "UpTime=="+strconv.FormatUint(t.lastStatus.Uptime/86400, 10), "Unit=="+t.I18nBot("tgbot.days")) - info += t.I18nBot("tgbot.messages.serverLoad", "Load1=="+strconv.FormatFloat(t.lastStatus.Loads[0], 'f', 2, 64), "Load2=="+strconv.FormatFloat(t.lastStatus.Loads[1], 'f', 2, 64), "Load3=="+strconv.FormatFloat(t.lastStatus.Loads[2], 'f', 2, 64)) - info += t.I18nBot("tgbot.messages.serverMemory", "Current=="+common.FormatTraffic(int64(t.lastStatus.Mem.Current)), "Total=="+common.FormatTraffic(int64(t.lastStatus.Mem.Total))) - info += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(len(onlines))) - info += t.I18nBot("tgbot.messages.tcpCount", "Count=="+strconv.Itoa(t.lastStatus.TcpCount)) - info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount)) - info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv))) - info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State)) - - // Cache the complete server stats - t.setCachedServerStats(info) - - return info -} - -// UserLoginNotify publishes a login event to the event bus. -func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) { - if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" { - logger.Warning("UserLoginNotify failed, invalid info!") - return - } - - if EventBus == nil { - return - } - - status := "fail" - if attempt.Status == LoginSuccess { - status = "success" - } - - EventBus.Publish(eventbus.Event{ - Type: eventbus.EventLoginAttempt, - Source: attempt.IP, - Data: &eventbus.LoginEventData{ - Username: attempt.Username, - IP: attempt.IP, - Time: attempt.Time, - Status: status, - Reason: attempt.Reason, - }, - }) -} - -// getExhausted retrieves and sends information about exhausted clients. -func (t *Tgbot) getExhausted(chatId int64) { - trDiff := int64(0) - exDiff := int64(0) - now := time.Now().Unix() * 1000 - var exhaustedInbounds []model.Inbound - var exhaustedClients []xray.ClientTraffic - var disabledInbounds []model.Inbound - var disabledClients []xray.ClientTraffic - - TrafficThreshold, err := t.settingService.GetTrafficDiff() - if err == nil && TrafficThreshold > 0 { - trDiff = int64(TrafficThreshold) * 1073741824 - } - ExpireThreshold, err := t.settingService.GetExpireDiff() - if err == nil && ExpireThreshold > 0 { - exDiff = int64(ExpireThreshold) * 86400000 - } - inbounds, err := t.inboundService.GetAllInbounds() - if err != nil { - logger.Warning("Unable to load Inbounds", err) - } - - seenClients := make(map[string]bool) - for _, inbound := range inbounds { - if inbound.Enable { - if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) || - (inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) { - exhaustedInbounds = append(exhaustedInbounds, *inbound) - } - if len(inbound.ClientStats) > 0 { - for _, client := range inbound.ClientStats { - if seenClients[client.Email] { - continue - } - seenClients[client.Email] = true - if client.Enable { - if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) || - (client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) { - exhaustedClients = append(exhaustedClients, client) - } - } else { - disabledClients = append(disabledClients, client) - } - } - } - } else { - disabledInbounds = append(disabledInbounds, *inbound) - } - } - - // Inbounds - output := "" - output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds")) - output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds))) - output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds))) - - if len(exhaustedInbounds) > 0 { - output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds")) - - for _, inbound := range exhaustedInbounds { - output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark) - output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)) - output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)) - if inbound.ExpiryTime == 0 { - output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")) - } else { - output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")) - } - output += "\r\n" - } - } - - // Clients - exhaustedCC := len(exhaustedClients) - output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")) - output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients))) - output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC)) - - if exhaustedCC > 0 { - output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients")) - var buttons []telego.InlineKeyboardButton - for _, traffic := range exhaustedClients { - output += t.clientInfoMsg(&traffic, true, false, false, true, true, false) - output += "\r\n" - buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email))) - } - cols := 0 - if exhaustedCC < 11 { - cols = 1 - } else { - cols = 2 - } - output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - t.SendMsgToTgbot(chatId, output, keyboard) - } else { - output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) - t.SendMsgToTgbot(chatId, output) - } -} - -// notifyExhausted sends notifications for exhausted clients. -func (t *Tgbot) notifyExhausted() { - trDiff := int64(0) - exDiff := int64(0) - now := time.Now().Unix() * 1000 - - TrafficThreshold, err := t.settingService.GetTrafficDiff() - if err == nil && TrafficThreshold > 0 { - trDiff = int64(TrafficThreshold) * 1073741824 - } - ExpireThreshold, err := t.settingService.GetExpireDiff() - if err == nil && ExpireThreshold > 0 { - exDiff = int64(ExpireThreshold) * 86400000 - } - inbounds, err := t.inboundService.GetAllInbounds() - if err != nil { - logger.Warning("Unable to load Inbounds", err) - } - - var chatIDsDone []int64 - for _, inbound := range inbounds { - if inbound.Enable { - if len(inbound.ClientStats) > 0 { - clients, err := t.inboundService.GetClients(inbound) - if err == nil { - for _, client := range clients { - if client.TgID != 0 { - chatID := client.TgID - if !int64Contains(chatIDsDone, chatID) && !checkAdmin(chatID) { - var disabledClients []xray.ClientTraffic - var exhaustedClients []xray.ClientTraffic - traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID) - if err == nil && len(traffics) > 0 { - var output strings.Builder - output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))) - for _, traffic := range traffics { - if traffic.Enable { - if (traffic.ExpiryTime > 0 && (traffic.ExpiryTime-now < exDiff)) || - (traffic.Total > 0 && (traffic.Total-(traffic.Up+traffic.Down) < trDiff)) { - exhaustedClients = append(exhaustedClients, *traffic) - } - } else { - disabledClients = append(disabledClients, *traffic) - } - } - if len(exhaustedClients) > 0 { - output.WriteString(t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))) - if len(disabledClients) > 0 { - output.WriteString(t.I18nBot("tgbot.clients")) - output.WriteString(":\r\n") - for _, traffic := range disabledClients { - output.WriteString(" ") - output.WriteString(traffic.Email) - } - output.WriteString("\r\n") - } - output.WriteString("\r\n") - output.WriteString(t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients)))) - for _, traffic := range exhaustedClients { - output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false)) - output.WriteString("\r\n") - } - t.SendMsgToTgbot(chatID, output.String()) - } - chatIDsDone = append(chatIDsDone, chatID) - } - } - } - } - } - } - } - } -} - -// onlineClients retrieves and sends information about online clients. -func (t *Tgbot) onlineClients(chatId int64, messageID ...int) { - process := service.XrayProcess() - if process == nil || !process.IsRunning() { - return - } - - onlines := process.GetOnlineClients() - onlinesCount := len(onlines) - output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount)) - keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh")))) - - if onlinesCount > 0 { - var buttons []telego.InlineKeyboardButton - for _, online := range onlines { - label := online - if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" { - label = online + " - " + inbound.Remark - } - buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online))) - } - cols := 0 - if onlinesCount < 21 { - cols = 2 - } else if onlinesCount < 61 { - cols = 3 - } else { - cols = 4 - } - keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...) - } - - if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], output, keyboard) - } else { - t.SendMsgToTgbot(chatId, output, keyboard) - } -} - -// sendBackup sends a backup of the database and configuration files. -func (t *Tgbot) sendBackup(chatId int64) { - dbData, err := t.serverService.GetDb() - if err != nil { - logger.Error("Error in getting db backup: ", err) - } - t.sendBackupData(chatId, dbData, t.serverService.BackupFilename("")) -} - -func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) { - output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) - output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05")) - t.SendMsgToTgbot(chatId, output) - - // Send database backup (SQLite file, or a pg_dump archive on PostgreSQL) - if dbData != nil { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - document := tu.Document( - tu.ID(chatId), - tu.FileFromBytes(dbData, dbFilename), - ) - _, err := bot.SendDocument(ctx, document) - cancel() - if err != nil { - logger.Error("Error in uploading backup: ", err) - } - } - - // Small delay between file sends - time.Sleep(500 * time.Millisecond) - - // Send config.json backup - file, err := os.Open(xray.GetConfigPath()) - if err == nil { - defer file.Close() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - document := tu.Document( - tu.ID(chatId), - tu.File(file), - ) - _, err = bot.SendDocument(ctx, document) - if err != nil { - logger.Error("Error in uploading config.json: ", err) - } - } else { - logger.Error("Error in opening config.json file for backup: ", err) - } -} - -// sendBanLogs sends the ban logs to the specified chat. -func (t *Tgbot) sendBanLogs(chatId int64, dt bool) { - if dt { - output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) - output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05")) - t.SendMsgToTgbot(chatId, output) - } - - file, err := os.Open(xray.GetIPLimitBannedPrevLogPath()) - if err == nil { - // Check if the file is non-empty before attempting to upload - fileInfo, _ := file.Stat() - if fileInfo.Size() > 0 { - document := tu.Document( - tu.ID(chatId), - tu.File(file), - ) - _, err = bot.SendDocument(context.Background(), document) - if err != nil { - logger.Error("Error in uploading IPLimitBannedPrevLog: ", err) - } - } else { - logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.") - } - file.Close() - } else { - logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err) - } - - file, err = os.Open(xray.GetIPLimitBannedLogPath()) - if err == nil { - // Check if the file is non-empty before attempting to upload - fileInfo, _ := file.Stat() - if fileInfo.Size() > 0 { - document := tu.Document( - tu.ID(chatId), - tu.File(file), - ) - _, err = bot.SendDocument(context.Background(), document) - if err != nil { - logger.Error("Error in uploading IPLimitBannedLog: ", err) - } - } else { - logger.Warning("IPLimitBannedLog file is empty, not uploading.") - } - file.Close() - } else { - logger.Error("Error in opening IPLimitBannedLog file for backup: ", err) - } -} diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go deleted file mode 100644 index bc45b41..0000000 --- a/internal/web/service/tgbot/tgbot_router.go +++ /dev/null @@ -1,1492 +0,0 @@ -package tgbot - -import ( - "context" - "fmt" - "html" - "slices" - "strconv" - "strings" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/logger" - - "github.com/mymmrac/telego" - th "github.com/mymmrac/telego/telegohandler" - tu "github.com/mymmrac/telego/telegoutil" -) - -// OnReceive starts the message receiving loop for the Telegram bot. -func (t *Tgbot) OnReceive() { - params := telego.GetUpdatesParams{ - Timeout: 20, // Reduced timeout to detect connection issues faster - } - // Strict singleton: never start a second long-polling loop. - tgBotMutex.Lock() - if botCancel != nil || isRunning { - tgBotMutex.Unlock() - logger.Warning("TgBot OnReceive called while already running; ignoring.") - return - } - - ctx, cancel := context.WithCancel(context.Background()) - botCancel = cancel - isRunning = true - // Add to WaitGroup before releasing the lock so StopBot() can't return - // before this receiver goroutine is accounted for. - botWG.Add(1) - tgBotMutex.Unlock() - - // Get updates channel using the context with shorter timeout for better error recovery - updates, _ := bot.UpdatesViaLongPolling(ctx, ¶ms) - go func() { - defer botWG.Done() - h, _ := th.NewBotHandler(bot, updates) - tgBotMutex.Lock() - botHandler = h - tgBotMutex.Unlock() - - h.HandleMessage(func(ctx *th.Context, message telego.Message) error { - userStateMgr.clear(message.Chat.ID) - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove()) - return nil - }, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard"))) - - h.HandleMessage(func(ctx *th.Context, message telego.Message) error { - if !t.isCommandForCurrentBot(&message) { - return nil - } - - // Use goroutine with worker pool for concurrent command processing - go func() { - messageWorkerPool <- struct{}{} // Acquire worker - defer func() { <-messageWorkerPool }() // Release worker - - userStateMgr.clear(message.Chat.ID) - t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) - }() - return nil - }, th.AnyCommand()) - - h.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error { - // Use goroutine with worker pool for concurrent callback processing - go func() { - messageWorkerPool <- struct{}{} // Acquire worker - defer func() { <-messageWorkerPool }() // Release worker - - userStateMgr.clear(query.Message.GetChat().ID) - t.answerCallback(&query, checkAdmin(query.From.ID)) - }() - return nil - }, th.AnyCallbackQueryWithMessage()) - - h.HandleMessage(func(ctx *th.Context, message telego.Message) error { - userStateMgr.maybePrune(time.Hour) - if userState, exists := userStateMgr.get(message.Chat.ID); exists { - switch userState { - case "awaiting_email": - if client_Email == strings.TrimSpace(message.Text) { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) - userStateMgr.clear(message.Chat.ID) - return nil - } - - client_Email = strings.TrimSpace(message.Text) - if t.isSingleWord(client_Email) { - userStateMgr.set(message.Chat.ID, "awaiting_email") - - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) - } else { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove()) - userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) - } - case "awaiting_comment": - if client_Comment == strings.TrimSpace(message.Text) { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) - userStateMgr.clear(message.Chat.ID) - return nil - } - - client_Comment = strings.TrimSpace(message.Text) - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove()) - userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) - case "awaiting_tg_id": - input := strings.TrimSpace(message.Text) - if input == "" || input == "-" || strings.EqualFold(input, "none") { - client_TgID = "" - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) - userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) - return nil - } - if _, err := strconv.ParseInt(input, 10, 64); err != nil { - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) - return nil - } - client_TgID = input - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove()) - userStateMgr.clear(message.Chat.ID) - t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) - } - } else { - if message.UsersShared != nil { - if checkAdmin(message.From.ID) { - for _, sharedUser := range message.UsersShared.Users { - userID := sharedUser.UserID - needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID) - if needRestart { - t.xrayService.SetToNeedRestart() - } - output := "" - if err != nil { - output += t.I18nBot("tgbot.messages.selectUserFailed") - } else { - output += t.I18nBot("tgbot.messages.userSaved") - } - t.SendMsgToTgbot(message.Chat.ID, output, tu.ReplyKeyboardRemove()) - } - } else { - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.noResult"), tu.ReplyKeyboardRemove()) - } - } - } - return nil - }, th.AnyMessage()) - - _ = h.Start() - }() -} - -// answerCommand processes incoming command messages from Telegram users. -func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin bool) { - msg, onlyMessage := "", false - - command, _, commandArgs := tu.ParseCommand(message.Text) - - // Helper function to handle unknown commands. - handleUnknownCommand := func() { - msg += t.I18nBot("tgbot.commands.unknown") - } - - // Handle the command. - switch command { - case "help": - msg += t.I18nBot("tgbot.commands.help") - msg += t.I18nBot("tgbot.commands.pleaseChoose") - case "start": - msg += t.I18nBot("tgbot.commands.start", "Firstname=="+html.EscapeString(message.From.FirstName)) - if isAdmin { - msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname) - } - msg += "\n\n" + t.I18nBot("tgbot.commands.pleaseChoose") - case "status": - onlyMessage = true - msg += t.I18nBot("tgbot.commands.status") - case "id": - onlyMessage = true - msg += t.I18nBot("tgbot.commands.getID", "ID=="+strconv.FormatInt(message.From.ID, 10)) - case "usage": - onlyMessage = true - if len(commandArgs) > 0 { - if isAdmin { - t.searchClient(chatId, commandArgs[0]) - } else { - t.getClientUsage(chatId, message.From.ID, commandArgs[0]) - } - } else { - msg += t.I18nBot("tgbot.commands.usage") - } - case "inbound": - onlyMessage = true - if isAdmin && len(commandArgs) > 0 { - t.searchInbound(chatId, commandArgs[0]) - } else { - handleUnknownCommand() - } - case "restart": - onlyMessage = true - if isAdmin { - if len(commandArgs) == 0 { - if t.xrayService.IsXrayRunning() { - err := t.xrayService.RestartXray(true) - if err != nil { - msg += t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error()) - } else { - msg += t.I18nBot("tgbot.commands.restartSuccess") - } - } else { - msg += t.I18nBot("tgbot.commands.xrayNotRunning") - } - } else { - handleUnknownCommand() - msg += t.I18nBot("tgbot.commands.restartUsage") - } - } else { - handleUnknownCommand() - } - case "clearall": - onlyMessage = true - if isAdmin { - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("reset_all_traffics_cancel")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_all_traffics_c")), - ), - ) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.AreYouSure"), inlineKeyboard) - } else { - handleUnknownCommand() - } - default: - handleUnknownCommand() - } - - if msg != "" { - t.sendResponse(chatId, msg, onlyMessage, isAdmin) - } -} - -func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool { - return isCommandForBot(message.Text, botUsername()) -} - -func botUsername() string { - if bot == nil { - return "" - } - return bot.Username() -} - -func isCommandForBot(text string, username string) bool { - _, commandUsername, _ := tu.ParseCommand(text) - return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username) -} - -// answerCallback processes callback queries from inline keyboards. -func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) { - chatId := callbackQuery.Message.GetChat().ID - - if isAdmin { - // get query from hash storage - decodedQuery, err := t.decodeQuery(callbackQuery.Data) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noQuery")) - return - } - dataArray := strings.Split(decodedQuery, " ") - - if len(dataArray) >= 2 && len(dataArray[1]) > 0 { - email := dataArray[1] - switch dataArray[0] { - case "get_clients_for_sub": - inboundId := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundId) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_sub_links") - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - inbound, _ := t.inboundService.GetInbound(inboundIdInt) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) - case "get_clients_for_individual": - inboundId := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundId) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_individual_links") - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - inbound, _ := t.inboundService.GetInbound(inboundIdInt) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) - case "get_clients_for_qr": - inboundId := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundId) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_qr_links") - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - inbound, _ := t.inboundService.GetInbound(inboundIdInt) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) - case "client_sub_links": - t.sendClientSubLinks(chatId, email) - return - case "client_individual_links": - t.sendClientIndividualLinks(chatId, email) - return - case "client_qr_links": - t.sendClientQRLinks(chatId, email) - return - case "client_get_usage": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) - t.searchClient(chatId, email) - case "client_refresh": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.clientRefreshSuccess", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "client_cancel": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "ips_refresh": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.IpRefreshSuccess", "Email=="+email)) - t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) - case "ips_cancel": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) - t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) - case "tgid_refresh": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.TGIdRefreshSuccess", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) - case "tgid_cancel": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) - case "reset_traffic": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic_c "+email)), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "reset_traffic_c": - err := t.inboundService.ResetClientTrafficByEmail(email) - if err == nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetTrafficSuccess", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - } else { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - } - case "limit_traffic": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 0")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" 0")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 1")), - tu.InlineKeyboardButton("5 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 5")), - tu.InlineKeyboardButton("10 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 10")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("20 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 20")), - tu.InlineKeyboardButton("30 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 30")), - tu.InlineKeyboardButton("40 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 40")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("50 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 50")), - tu.InlineKeyboardButton("60 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 60")), - tu.InlineKeyboardButton("80 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 80")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("100 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 100")), - tu.InlineKeyboardButton("150 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 150")), - tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 200")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "limit_traffic_c": - if len(dataArray) == 3 { - limitTraffic, err := strconv.Atoi(dataArray[2]) - if err == nil { - needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic) - if needRestart { - t.xrayService.SetToNeedRestart() - } - if err == nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.setTrafficLimitSuccess", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - return - } - } - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "limit_traffic_in": - if len(dataArray) >= 3 { - oldInputNumber, err := strconv.Atoi(dataArray[2]) - inputNumber := oldInputNumber - if err == nil { - if len(dataArray) == 4 { - num, err := strconv.Atoi(dataArray[3]) - if err == nil { - inputNumber = updateNumericInput(inputNumber, num) - } - if inputNumber == oldInputNumber { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - return - } - if inputNumber >= 999999 { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - return - } - } - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" "+strconv.Itoa(inputNumber))), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 2")), - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 3")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 4")), - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 6")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 7")), - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 9")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("πŸ”„").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" -2")), - tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 0")), - tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" -1")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - return - } - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "add_client_limit_traffic_c": - limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64) - client_TotalGB = limitTraffic * 1024 * 1024 * 1024 - messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() - - t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - case "add_client_limit_traffic_in": - if len(dataArray) >= 2 { - oldInputNumber, err := strconv.Atoi(dataArray[1]) - inputNumber := oldInputNumber - if err == nil { - if len(dataArray) == 3 { - num, err := strconv.Atoi(dataArray[2]) - if err == nil { - inputNumber = updateNumericInput(inputNumber, num) - } - if inputNumber == oldInputNumber { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - return - } - if inputNumber >= 999999 { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - return - } - } - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_limit_traffic_c "+strconv.Itoa(inputNumber))), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 2")), - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 3")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 4")), - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 6")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 7")), - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 9")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("πŸ”„").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" -2")), - tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 0")), - tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" -1")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - return - } - } - case "reset_exp": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 0")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("reset_exp_in "+email+" 0")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 7")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 10")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 14")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 20")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 30")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 90")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 180")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 365")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "reset_exp_c": - if len(dataArray) == 3 { - days, err := strconv.ParseInt(dataArray[2], 10, 64) - if err == nil { - var date int64 - if days > 0 { - traffic, err := t.inboundService.GetClientTrafficByEmail(email) - if err != nil { - logger.Warning(err) - msg := t.I18nBot("tgbot.wentWrong") - t.SendMsgToTgbot(chatId, msg) - return - } - if traffic == nil { - msg := t.I18nBot("tgbot.noResult") - t.SendMsgToTgbot(chatId, msg) - return - } - - if traffic.ExpiryTime > 0 { - if traffic.ExpiryTime-time.Now().Unix()*1000 < 0 { - date = -(days * 24 * 60 * 60000) - } else { - date = traffic.ExpiryTime + days*24*60*60000 - } - } else { - date = traffic.ExpiryTime - days*24*60*60000 - } - - } - needRestart, err := t.clientService.ResetClientExpiryTimeByEmail(&t.inboundService, email, date) - if needRestart { - t.xrayService.SetToNeedRestart() - } - if err == nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.expireResetSuccess", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - return - } - } - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "reset_exp_in": - if len(dataArray) >= 3 { - oldInputNumber, err := strconv.Atoi(dataArray[2]) - inputNumber := oldInputNumber - if err == nil { - if len(dataArray) == 4 { - num, err := strconv.Atoi(dataArray[3]) - if err == nil { - inputNumber = updateNumericInput(inputNumber, num) - } - if inputNumber == oldInputNumber { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - return - } - if inputNumber >= 999999 { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - return - } - } - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" "+strconv.Itoa(inputNumber))), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 2")), - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 3")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 4")), - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 6")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 7")), - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 9")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("πŸ”„").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" -2")), - tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 0")), - tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" -1")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - return - } - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "add_client_reset_exp_c": - client_ExpiryTime = 0 - days, _ := strconv.ParseInt(dataArray[1], 10, 64) - var date int64 - if client_ExpiryTime > 0 { - if client_ExpiryTime-time.Now().Unix()*1000 < 0 { - date = -(days * 24 * 60 * 60000) - } else { - date = client_ExpiryTime + days*24*60*60000 - } - } else { - date = client_ExpiryTime - days*24*60*60000 - } - client_ExpiryTime = date - - messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() - - t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - case "add_client_reset_exp_in": - if len(dataArray) >= 2 { - oldInputNumber, err := strconv.Atoi(dataArray[1]) - inputNumber := oldInputNumber - if err == nil { - if len(dataArray) == 3 { - num, err := strconv.Atoi(dataArray[2]) - if err == nil { - inputNumber = updateNumericInput(inputNumber, num) - } - if inputNumber == oldInputNumber { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - return - } - if inputNumber >= 999999 { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - return - } - } - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_reset_exp_c "+strconv.Itoa(inputNumber))), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 2")), - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 3")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 4")), - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 6")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 7")), - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 9")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("πŸ”„").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" -2")), - tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 0")), - tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" -1")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - return - } - } - case "ip_limit": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelIpLimit")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 0")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("ip_limit_in "+email+" 0")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 2")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 3")), - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 4")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 6")), - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 7")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 9")), - tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 10")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "ip_limit_c": - if len(dataArray) == 3 { - count, err := strconv.Atoi(dataArray[2]) - if err == nil { - needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count) - if needRestart { - t.xrayService.SetToNeedRestart() - } - if err == nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetIpSuccess", "Email=="+email, "Count=="+strconv.Itoa(count))) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - return - } - } - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "ip_limit_in": - if len(dataArray) >= 3 { - oldInputNumber, err := strconv.Atoi(dataArray[2]) - inputNumber := oldInputNumber - if err == nil { - if len(dataArray) == 4 { - num, err := strconv.Atoi(dataArray[3]) - if err == nil { - inputNumber = updateNumericInput(inputNumber, num) - } - if inputNumber == oldInputNumber { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - return - } - if inputNumber >= 999999 { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - return - } - } - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("ip_limit_c "+email+" "+strconv.Itoa(inputNumber))), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 2")), - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 3")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 4")), - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 6")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 7")), - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 9")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("πŸ”„").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" -2")), - tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 0")), - tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" -1")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - return - } - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - case "add_client_ip_limit_c": - if len(dataArray) == 2 { - count, _ := strconv.Atoi(dataArray[1]) - client_LimitIP = count - } - - messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() - - t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - case "add_client_ip_limit_in": - if len(dataArray) >= 2 { - oldInputNumber, err := strconv.Atoi(dataArray[1]) - inputNumber := oldInputNumber - if err == nil { - if len(dataArray) == 3 { - num, err := strconv.Atoi(dataArray[2]) - if err == nil { - inputNumber = updateNumericInput(inputNumber, num) - } - if inputNumber == oldInputNumber { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - return - } - if inputNumber >= 999999 { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - return - } - } - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_ip_limit")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_ip_limit_c "+strconv.Itoa(inputNumber))), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 2")), - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 3")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 4")), - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 6")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 7")), - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 9")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("πŸ”„").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" -2")), - tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 0")), - tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" -1")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - return - } - } - case "clear_ips": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("ips_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmClearIps")).WithCallbackData(t.encodeQuery("clear_ips_c "+email)), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "clear_ips_c": - err := t.inboundService.ClearClientIps(email) - if err == nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.clearIpSuccess", "Email=="+email)) - t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) - } else { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - } - case "ip_log": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getIpLog", "Email=="+email)) - t.searchClientIps(chatId, email) - case "tg_user": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getUserInfo", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email) - case "tgid_remove": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("tgid_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmRemoveTGUser")).WithCallbackData(t.encodeQuery("tgid_remove_c "+email)), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "tgid_remove_c": - traffic, err := t.inboundService.GetClientTrafficByEmail(email) - if err != nil || traffic == nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - return - } - needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, traffic.Id, EmptyTelegramUserID) - if needRestart { - t.xrayService.SetToNeedRestart() - } - if err == nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.removedTGUserSuccess", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) - } else { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - } - case "toggle_enable": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmToggle")).WithCallbackData(t.encodeQuery("toggle_enable_c "+email)), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "toggle_enable_c": - enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email) - if needRestart { - t.xrayService.SetToNeedRestart() - } - if err == nil { - if enabled { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.enableSuccess", "Email=="+email)) - } else { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.disableSuccess", "Email=="+email)) - } - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) - } else { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - } - case "get_clients": - inboundId := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundId) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - inbound, err := t.inboundService.GetInbound(inboundIdInt) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - clients, err := t.getInboundClients(inboundIdInt) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clients) - case "add_client_to": - client_Email = t.randomLowerAndNum(8) - client_LimitIP = 0 - client_TotalGB = 0 - client_ExpiryTime = 0 - client_Enable = true - client_TgID = "" - client_SubID = t.randomLowerAndNum(16) - client_Comment = "" - client_Reset = 0 - - inboundId := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundId) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - receiver_inbound_ID = inboundIdInt - receiver_inbound_IDs = []int{inboundIdInt} - t.addClient(callbackQuery.Message.GetChat().ID, t.BuildClientDraftMessage()) - case "add_client_toggle_attach": - inboundIdStr := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundIdStr) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - found := -1 - for i, id := range receiver_inbound_IDs { - if id == inboundIdInt { - found = i - break - } - } - if found >= 0 { - receiver_inbound_IDs = append(receiver_inbound_IDs[:found], receiver_inbound_IDs[found+1:]...) - } else { - receiver_inbound_IDs = append(receiver_inbound_IDs, inboundIdInt) - } - picker, err := t.getInboundsAttachPicker() - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - t.editMessageCallbackTgBot(callbackQuery.Message.GetChat().ID, callbackQuery.Message.GetMessageID(), picker) - } - return - } else { - switch callbackQuery.Data { - case "get_inbounds": - inbounds, err := t.getInbounds() - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients")) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) - case "admin_client_sub_links": - inbounds, err := t.getInboundsFor("get_clients_for_sub") - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) - case "admin_client_individual_links": - inbounds, err := t.getInboundsFor("get_clients_for_individual") - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) - case "admin_client_qr_links": - inbounds, err := t.getInboundsFor("get_clients_for_qr") - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) - } - } - } - - if !isAdmin && !isClientSelfCallback(callbackQuery.Data) { - return - } - - switch callbackQuery.Data { - case "get_usage": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.serverUsage")) - t.getServerUsage(chatId) - case "usage_refresh": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - t.getServerUsage(chatId, callbackQuery.Message.GetMessageID()) - case "inbounds": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.getInbounds")) - t.SendMsgToTgbot(chatId, t.getInboundUsages()) - case "deplete_soon": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.depleteSoon")) - t.getExhausted(chatId) - case "get_backup": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.dbBackup")) - t.sendBackup(chatId) - case "get_banlogs": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.getBanLogs")) - t.sendBanLogs(chatId, true) - case "client_traffic": - tgUserID := callbackQuery.From.ID - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.clientUsage")) - t.getClientUsage(chatId, tgUserID) - case "client_commands": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands")) - case "client_sub_links": - // show user's own clients to choose one for sub links - tgUserID := callbackQuery.From.ID - traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) - if err != nil { - // fallback to message - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - if len(traffics) == 0 { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) - return - } - var buttons []telego.InlineKeyboardButton - for _, tr := range traffics { - buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_sub_links "+tr.Email))) - } - cols := 1 - if len(buttons) >= 6 { - cols = 2 - } - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard) - case "client_individual_links": - // show user's clients to choose for individual links - tgUserID := callbackQuery.From.ID - traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - if len(traffics) == 0 { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) - return - } - var buttons2 []telego.InlineKeyboardButton - for _, tr := range traffics { - buttons2 = append(buttons2, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_individual_links "+tr.Email))) - } - cols2 := 1 - if len(buttons2) >= 6 { - cols2 = 2 - } - keyboard2 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols2, buttons2...)) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard2) - case "client_qr_links": - // show user's clients to choose for QR codes - tgUserID := callbackQuery.From.ID - traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOccurred")+"\r\n"+err.Error()) - return - } - if len(traffics) == 0 { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) - return - } - var buttons3 []telego.InlineKeyboardButton - for _, tr := range traffics { - buttons3 = append(buttons3, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_qr_links "+tr.Email))) - } - cols3 := 1 - if len(buttons3) >= 6 { - cols3 = 2 - } - keyboard3 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols3, buttons3...)) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard3) - case "onlines": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines")) - t.onlineClients(chatId) - case "onlines_refresh": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) - t.onlineClients(chatId, callbackQuery.Message.GetMessageID()) - case "commands": - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpAdminCommands")) - case "add_client": - client_Email = t.randomLowerAndNum(8) - client_LimitIP = 0 - client_TotalGB = 0 - client_ExpiryTime = 0 - client_Enable = true - client_TgID = "" - client_SubID = t.randomLowerAndNum(16) - client_Comment = "" - client_Reset = 0 - - inbounds, err := t.getInboundsAddClient() - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.addClient")) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) - case "add_client_ch_default_email": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - userStateMgr.set(chatId, "awaiting_email") - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+client_Email) - t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) - case "add_client_ch_default_comment": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - userStateMgr.set(chatId, "awaiting_comment") - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment) - t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) - case "add_client_ch_default_tg_id": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - userStateMgr.set(chatId, "awaiting_tg_id") - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) - current := client_TgID - if current == "" { - current = "β€”" - } - t.SendMsgToTgbot(chatId, fmt.Sprintf("Send the Telegram user id (numeric) to attach to this client, or send `-` / `none` to clear.\nCurrent: `%s`", current), cancel_btn_markup) - case "add_client_ch_default_traffic": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 0")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_limit_traffic_in 0")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 1")), - tu.InlineKeyboardButton("5 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 5")), - tu.InlineKeyboardButton("10 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 10")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("20 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 20")), - tu.InlineKeyboardButton("30 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 30")), - tu.InlineKeyboardButton("40 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 40")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("50 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 50")), - tu.InlineKeyboardButton("60 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 60")), - tu.InlineKeyboardButton("80 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 80")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("100 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 100")), - tu.InlineKeyboardButton("150 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 150")), - tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 200")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "add_client_ch_default_exp": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 0")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_reset_exp_in 0")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 7")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 10")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 14")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 20")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 30")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 90")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 180")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 365")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "add_client_ch_default_ip_limit": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_ip_limit")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_ip_limit_c 0")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_ip_limit_in 0")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 1")), - tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 2")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 3")), - tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 4")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 5")), - tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 6")), - tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 7")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 8")), - tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 9")), - tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 10")), - ), - ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) - case "add_client_default_info": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) - userStateMgr.clear(chatId) - t.addClient(chatId, t.BuildClientDraftMessage()) - case "add_client_cancel": - userStateMgr.clear(chatId) - receiver_inbound_ID = 0 - receiver_inbound_IDs = nil - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.cancel"), 3, tu.ReplyKeyboardRemove()) - case "add_client_default_traffic_exp": - messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() - t.addClient(chatId, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) - case "add_client_default_ip_limit": - messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage() - t.addClient(chatId, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) - case "add_client_attach_more": - picker, err := t.getInboundsAttachPicker() - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) - return - } - t.SendMsgToTgbot(chatId, "Pick inbound(s) to attach:", picker) - case "add_client_attach_done": - if receiver_inbound_ID == 0 && len(receiver_inbound_IDs) > 0 { - receiver_inbound_ID = receiver_inbound_IDs[0] - } - if receiver_inbound_ID == 0 { - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed")) - return - } - message_text := t.BuildClientDraftMessage() - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.addClient(chatId, message_text) - case "add_client_submit_disable": - client_Enable = false - _, err := t.SubmitAddClient() - if err != nil { - errorMessage := fmt.Sprintf("%v", err) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove()) - } else { - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) - t.sendClientIndividualLinks(chatId, client_Email) - t.sendClientQRLinks(chatId, client_Email) - receiver_inbound_ID = 0 - receiver_inbound_IDs = nil - } - case "add_client_submit_enable": - client_Enable = true - _, err := t.SubmitAddClient() - if err != nil { - errorMessage := fmt.Sprintf("%v", err) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove()) - } else { - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) - t.sendClientIndividualLinks(chatId, client_Email) - t.sendClientQRLinks(chatId, client_Email) - receiver_inbound_ID = 0 - receiver_inbound_IDs = nil - } - case "reset_all_traffics_cancel": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.cancel"), 1, tu.ReplyKeyboardRemove()) - case "reset_all_traffics": - inlineKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("reset_all_traffics_cancel")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_all_traffics_c")), - ), - ) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.AreYouSure"), inlineKeyboard) - case "reset_all_traffics_c": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - emails, err := t.inboundService.GetAllEmails() - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) - return - } - - for _, email := range emails { - err := t.inboundService.ResetClientTrafficByEmail(email) - if err == nil { - msg := t.I18nBot("tgbot.messages.SuccessResetTraffic", "ClientEmail=="+email) - t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) - } else { - msg := t.I18nBot("tgbot.messages.FailedResetTraffic", "ClientEmail=="+email, "ErrorMessage=="+err.Error()) - t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) - } - } - - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.FinishProcess"), tu.ReplyKeyboardRemove()) - case "get_sorted_traffic_usage_report": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - emails, err := t.inboundService.GetAllEmails() - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) - return - } - valid_emails, extra_emails, err := t.inboundService.FilterAndSortClientEmails(emails) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) - return - } - - for _, valid_emails := range valid_emails { - traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails) - if err != nil { - logger.Warning(err) - msg := t.I18nBot("tgbot.wentWrong") - t.SendMsgToTgbot(chatId, msg) - continue - } - if traffic == nil { - msg := t.I18nBot("tgbot.noResult") - t.SendMsgToTgbot(chatId, msg) - continue - } - - output := t.clientInfoMsg(traffic, false, false, false, false, true, false) - t.SendMsgToTgbot(chatId, output, tu.ReplyKeyboardRemove()) - } - for _, extra_emails := range extra_emails { - msg := fmt.Sprintf("πŸ“§ %s\n%s", extra_emails, t.I18nBot("tgbot.noResult")) - t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) - - } - default: - if after, ok := strings.CutPrefix(callbackQuery.Data, "client_sub_links "); ok { - email := after - t.sendClientSubLinks(chatId, email) - return - } - if after, ok := strings.CutPrefix(callbackQuery.Data, "client_individual_links "); ok { - email := after - t.sendClientIndividualLinks(chatId, email) - return - } - if after, ok := strings.CutPrefix(callbackQuery.Data, "client_qr_links "); ok { - email := after - t.sendClientQRLinks(chatId, email) - return - } - } -} - -// checkAdmin checks if the given Telegram ID is an admin. -func checkAdmin(tgId int64) bool { - return slices.Contains(adminIds, tgId) -} - -// isClientSelfCallback reports whether a callback is one of the per-user client -// actions that resolve their own data from the caller's Telegram id, and so are -// safe to run for a non-admin. Every other callback is admin-only (default-deny). -func isClientSelfCallback(data string) bool { - switch data { - case "client_traffic", "client_commands", "client_sub_links", - "client_individual_links", "client_qr_links": - return true - } - return strings.HasPrefix(data, "client_sub_links ") || - strings.HasPrefix(data, "client_individual_links ") || - strings.HasPrefix(data, "client_qr_links ") -} diff --git a/internal/web/service/tgbot/tgbot_send.go b/internal/web/service/tgbot/tgbot_send.go deleted file mode 100644 index b86a13b..0000000 --- a/internal/web/service/tgbot/tgbot_send.go +++ /dev/null @@ -1,296 +0,0 @@ -package tgbot - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/logger" - - "github.com/mymmrac/telego" - tu "github.com/mymmrac/telego/telegoutil" -) - -// sendResponse sends the response message based on the onlyMessage flag. -func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) { - if onlyMessage { - t.SendMsgToTgbot(chatId, msg) - } else { - t.SendAnswer(chatId, msg, isAdmin) - } -} - -// SendAnswer sends a response message with an inline keyboard to the specified chat. -func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) { - numericKeyboard := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")), - tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")), - tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")), - ), - // TODOOOOOOOOOOOOOO: Add restart button here. - ) - numericKeyboardClient := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")), - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")), - tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")), - ), - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")), - ), - ) - - var ReplyMarkup telego.ReplyMarkup - if isAdmin { - ReplyMarkup = numericKeyboard - } else { - ReplyMarkup = numericKeyboardClient - } - t.SendMsgToTgbot(chatId, msg, ReplyMarkup) -} - -const telegramPageLimit = 2000 - -func pageMessage(message string, limit int) []string { - if len(message) <= limit { - return []string{message} - } - - pages := make([]string, 0) - for _, block := range strings.Split(message, "\r\n\r\n") { - for _, page := range splitMessageLines(block, limit) { - last := len(pages) - 1 - if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit { - pages[last] += "\r\n\r\n" + page - continue - } - pages = append(pages, page) - } - } - if len(pages) > 0 && strings.TrimSpace(pages[len(pages)-1]) == "" { - pages = pages[:len(pages)-1] - } - return pages -} - -func splitMessageLines(block string, limit int) []string { - if len(block) <= limit { - return []string{block} - } - - lines := strings.Split(block, "\r\n") - pages := []string{lines[0]} - for _, line := range lines[1:] { - last := len(pages) - 1 - if len(pages[last])+len("\r\n")+len(line) > limit { - pages = append(pages, line) - continue - } - pages[last] += "\r\n" + line - } - return pages -} - -// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup. -func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) { - if !isRunning { - return - } - - if msg == "" { - logger.Info("[tgbot] message is empty!") - return - } - - allMessages := pageMessage(msg, telegramPageLimit) - for n, message := range allMessages { - params := telego.SendMessageParams{ - ChatID: tu.ID(chatId), - Text: message, - ParseMode: "HTML", - } - // only add replyMarkup to last message - if len(replyMarkup) > 0 && n == (len(allMessages)-1) { - params.ReplyMarkup = replyMarkup[0] - } - - // Retry logic with exponential backoff for connection errors - maxRetries := 3 - for attempt := range maxRetries { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - _, err := bot.SendMessage(ctx, ¶ms) - cancel() - - if err == nil { - break // Success - } - - // Check if error is a connection error - errStr := err.Error() - isConnectionError := strings.Contains(errStr, "connection") || - strings.Contains(errStr, "timeout") || - strings.Contains(errStr, "closed") - - if isConnectionError && attempt < maxRetries-1 { - // Exponential backoff: 1s, 2s, 4s - backoff := time.Duration(1< 0 { - for _, adminId := range adminIds { - t.SendMsgToTgbot(adminId, msg, replyMarkup[0]) - } - } else { - for _, adminId := range adminIds { - t.SendMsgToTgbot(adminId, msg) - } - } -} - -// sendCallbackAnswerTgBot answers a callback query with a message. -func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) { - params := telego.AnswerCallbackQueryParams{ - CallbackQueryID: id, - Text: message, - } - if err := bot.AnswerCallbackQuery(context.Background(), ¶ms); err != nil { - logger.Warning(err) - } -} - -// editMessageCallbackTgBot edits the reply markup of a message. -func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) { - params := telego.EditMessageReplyMarkupParams{ - ChatID: tu.ID(chatId), - MessageID: messageID, - ReplyMarkup: inlineKeyboard, - } - if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil { - logger.Warning(err) - } -} - -// editMessageTgBot edits the text and reply markup of a message. -func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) { - params := telego.EditMessageTextParams{ - ChatID: tu.ID(chatId), - MessageID: messageID, - Text: text, - ParseMode: "HTML", - } - if len(inlineKeyboard) > 0 { - params.ReplyMarkup = inlineKeyboard[0] - } - if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil { - logger.Warning(err) - } -} - -// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay. -func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) { - // Determine if replyMarkup was passed; otherwise, set it to nil - var replyMarkupParam telego.ReplyMarkup - if len(replyMarkup) > 0 { - replyMarkupParam = replyMarkup[0] // Use the first element - } - - // Send the message - sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{ - ChatID: tu.ID(chatId), - Text: msg, - ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value - }) - if err != nil { - logger.Warning("Failed to send message:", err) - return - } - - // Delete the sent message after the specified number of seconds. - go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds) -} - -// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It -// deliberately does not touch the conversation state: every caller that ends a -// wizard step already clears the state synchronously, and clearing it here β€” up -// to several seconds later β€” would wipe a state the user set for the next step -// in the meantime, silently dropping their following input. -func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) { - time.Sleep(time.Duration(delayInSeconds) * time.Second) - t.deleteMessageTgBot(chatId, messageID) -} - -// deleteMessageTgBot deletes a message from the chat. -func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) { - if bot == nil { - return - } - params := telego.DeleteMessageParams{ - ChatID: tu.ID(chatId), - MessageID: messageID, - } - if err := bot.DeleteMessage(context.Background(), ¶ms); err != nil { - logger.Warning("Failed to delete message:", err) - } else { - logger.Info("Message deleted successfully") - } -} - -// TestConnection verifies the bot token is valid and the API is reachable. -func (t *Tgbot) TestConnection() error { - tgBotMutex.Lock() - b := bot - tgBotMutex.Unlock() - if b == nil { - return fmt.Errorf("bot not initialized") - } - me, err := b.GetMe(context.Background()) - if err != nil { - return fmt.Errorf("API unreachable: %w", err) - } - _ = me - return nil -} diff --git a/internal/web/service/tgbot/tgbot_send_test.go b/internal/web/service/tgbot/tgbot_send_test.go deleted file mode 100644 index 0541627..0000000 --- a/internal/web/service/tgbot/tgbot_send_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package tgbot - -import ( - "strings" - "testing" -) - -func TestPageMessageSplitsLinkListWithoutBlankLines(t *testing.T) { - var message strings.Builder - message.WriteString("Individual links:\r\n") - for range 50 { - message.WriteString("vless://" + strings.Repeat("a", 300) + "\r\n") - } - - pages := pageMessage(message.String(), telegramPageLimit) - if len(pages) < 2 { - t.Fatalf("pageMessage() returned %d page, want multiple", len(pages)) - } - - links := 0 - for index, page := range pages { - if len(page) > telegramPageLimit { - t.Errorf("page %d has %d bytes, want at most %d", index, len(page), telegramPageLimit) - } - openingTags := strings.Count(page, "") - closingTags := strings.Count(page, "") - if openingTags != closingTags { - t.Errorf("page %d has %d opening tags and %d closing tags", index, openingTags, closingTags) - } - links += openingTags - } - if links != 50 { - t.Errorf("pages contain %d links, want 50", links) - } -} diff --git a/internal/web/service/tgbot/tgbot_test.go b/internal/web/service/tgbot/tgbot_test.go deleted file mode 100644 index 77f8202..0000000 --- a/internal/web/service/tgbot/tgbot_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package tgbot - -import ( - "io" - "net" - "reflect" - "testing" - "time" -) - -func TestLoginAttemptDoesNotCarryPassword(t *testing.T) { - typ := reflect.TypeFor[LoginAttempt]() - if _, ok := typ.FieldByName("Password"); ok { - t.Fatal("LoginAttempt must not carry attempted passwords") - } -} - -func TestIsSupportedBotProxyScheme(t *testing.T) { - supported := []string{ - "socks5://127.0.0.1:1080", - "http://127.0.0.1:8080", - "https://127.0.0.1:8080", - } - for _, p := range supported { - if !isSupportedBotProxyScheme(p) { - t.Errorf("expected %q to be supported", p) - } - } - unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"} - for _, p := range unsupported { - if isSupportedBotProxyScheme(p) { - t.Errorf("expected %q to be unsupported", p) - } - } -} - -func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) { - t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - got = make(chan []byte, 1) - t.Cleanup(func() { _ = ln.Close() }) - go func() { - conn, err := ln.Accept() - if err != nil { - return - } - defer conn.Close() - _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - buf := make([]byte, n) - m, _ := io.ReadFull(conn, buf) - got <- buf[:m] - }() - return ln.Addr().String(), got -} - -func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) { - addr, got := recordingDialTarget(t, len("CONNECT ")) - tg := &Tgbot{} - client := tg.createRobustFastHTTPClient("http://" + addr) - if client.Dial == nil { - t.Fatal("Dial must be set for an http:// proxy") - } - go func() { _, _ = client.Dial("example.com:443") }() - select { - case b := <-got: - if string(b) != "CONNECT " { - t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b) - } - case <-time.After(3 * time.Second): - t.Fatal("proxy never received a connection") - } -} - -func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) { - addr, got := recordingDialTarget(t, 1) - tg := &Tgbot{} - client := tg.createRobustFastHTTPClient("socks5://" + addr) - if client.Dial == nil { - t.Fatal("Dial must be set for a socks5:// proxy") - } - go func() { _, _ = client.Dial("example.com:443") }() - select { - case b := <-got: - if len(b) != 1 || b[0] != 0x05 { - t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b) - } - case <-time.After(3 * time.Second): - t.Fatal("proxy never received a connection") - } -} - -func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) { - tg := &Tgbot{} - client := tg.createRobustFastHTTPClient("") - if client.Dial != nil { - t.Fatal("Dial must be nil when no proxy is configured") - } -} - -func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) { - if !isCommandForBot("/status", "panel_bot") { - t.Fatal("untargeted commands must remain accepted") - } -} - -func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) { - if !isCommandForBot("/status@panel_bot", "Panel_Bot") { - t.Fatal("commands targeted to this bot must be accepted") - } -} - -func TestIsCommandForBotRejectsOtherUsername(t *testing.T) { - if isCommandForBot("/status@other_bot", "panel_bot") { - t.Fatal("commands targeted to another bot must be ignored") - } -} - -func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) { - if !isCommandForBot("/status@panel_bot", "") { - t.Fatal("commands must remain accepted when the current bot username is unavailable") - } -} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 30742a3..d5bcab0 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -77,7 +77,7 @@ func (s *XrayService) IsXrayRunning() bool { // XrayProcess returns the current Xray process instance (may be nil when Xray // is not running). It exposes the lifecycle snapshot to callers outside this -// package (e.g. the tgbot subpackage). +// package. func XrayProcess() *xray.Process { return currentXrayProcess() } diff --git a/internal/web/web.go b/internal/web/web.go index ab8120a..2dc8cb9 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -31,7 +31,6 @@ 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" @@ -119,7 +118,6 @@ type Server struct { xrayService service.XrayService settingService service.SettingService - tgbotService tgbot.Tgbot wsHub *websocket.Hub @@ -220,7 +218,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, &s.settingService) + err = locale.InitLocalizer(i18nFS) if err != nil { return nil, err } @@ -371,36 +369,6 @@ 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 monitor publishes cpu.high events; register it whenever any notifier - // (Telegram or Email) wants them, independent of the Telegram bot being on. - if s.cpuAlarmWanted() { - _, _ = s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob()) - } - // Memory monitor publishes memory.high events; register it whenever any notifier wants them. - 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() { @@ -410,63 +378,16 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) { } } -// cpuAlarmWanted reports whether any notifier is configured to receive cpu.high -// alerts, so the minute-long blocking CPU sampler only runs when it's needed. -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 alerts. -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, true) + return s.start(true) } func (s *Server) StartPanelOnly() (err error) { - return s.start(false, true) + return s.start(false) } -func (s *Server) start(restartXray bool, startTgBot bool) (err error) { +func (s *Server) start(restartXray bool) (err error) { // This is an anonymous function, no function name defer func() { if err != nil { @@ -589,7 +510,6 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { s.bus = eventbus.New(eventbus.DefaultBufferSize) service.SetEventBus(s.bus) job.EventBus = s.bus - tgbot.EventBus = s.bus // Wire xray crash callback BEFORE startTask so it's ready xray.OnCrash = func(err error) { @@ -601,65 +521,21 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { } } - // 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) - } - }) - s.startTask(restartXray, loc) - if startTgBot { - isTgbotenabled, err := s.settingService.GetTgbotEnabled() - if (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 } -// Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot. +// Stop gracefully shuts down the web server, stops Xray, and cron jobs. func (s *Server) Stop() error { - return s.stop(true, true) + return s.stop(true) } func (s *Server) StopPanelOnly() error { - return s.stop(false, true) + return s.stop(false) } -func (s *Server) stop(stopXray bool, stopTgBot bool) error { +func (s *Server) stop(stopXray bool) error { s.cancel() if stopXray { _ = s.xrayService.StopXray() @@ -678,9 +554,6 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { if stopXray { service.StopTrafficWriter() } - if stopTgBot && s.tgbotService.IsRunning() { - s.tgbotService.Stop() - } // Gracefully stop WebSocket hub if s.wsHub != nil { s.wsHub.Stop() diff --git a/main.go b/main.go index f45c361..1222d4a 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/global" "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/joho/godotenv" "github.com/op/go-logging" @@ -237,10 +236,6 @@ func runWebServer() { stopTunnelHealthMonitor() } - // --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown --- - tgbot.StopBot() - // ------------------------------------------------------------ - _ = server.Stop() _ = subServer.Stop() log.Println("Shutting down servers.") @@ -318,66 +313,6 @@ func showSetting(show bool) { } } -// updateTgbotEnableSts enables or disables the Telegram bot notifications based on the status parameter. -func updateTgbotEnableSts(status bool) { - settingService := service.SettingService{} - currentTgSts, err := settingService.GetTgbotEnabled() - if err != nil { - fmt.Println(err) - return - } - logger.Infof("current enabletgbot status[%v],need update to status[%v]", currentTgSts, status) - if currentTgSts != status { - err := settingService.SetTgbotEnabled(status) - if err != nil { - fmt.Println(err) - return - } else { - logger.Infof("SetTgbotEnabled[%v] success", status) - } - } -} - -// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule. -func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) { - err := database.InitDB(config.GetDBPath()) - if err != nil { - fmt.Println("Error initializing database:", err) - return - } - - settingService := service.SettingService{} - - if tgBotToken != "" { - err := settingService.SetTgBotToken(tgBotToken) - if err != nil { - fmt.Printf("Error setting Telegram bot token: %v\n", err) - return - } - logger.Info("Successfully updated Telegram bot token.") - } - - if tgBotRuntime != "" { - err := settingService.SetTgbotRuntime(tgBotRuntime) - if err != nil { - fmt.Printf("Error setting Telegram bot runtime: %v\n", err) - return - } - logger.Infof("Successfully updated Telegram bot runtime to [%s].", tgBotRuntime) - } - - if tgBotChatid != "" { - err := settingService.SetTgBotChatId(tgBotChatid) - if err != nil { - fmt.Printf("Error setting Telegram bot chat ID: %v\n", err) - return - } - logger.Info("Successfully updated Telegram bot chat ID.") - } -} - -// encryptNodeTokens re-encrypts stored tokens after enablement or rotation. -// It requires migration|required mode and a configured key. func encryptNodeTokens() { _ = godotenv.Load() if err := initNodeTokenCrypto(); err != nil { @@ -625,10 +560,6 @@ func main() { var getListen bool var webCertFile string var webKeyFile string - var tgbottoken string - var tgbotchatid string - var enabletgbot bool - var tgbotRuntime string var reset bool var show bool var getCert bool @@ -645,10 +576,6 @@ func main() { settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token") settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel") settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel") - settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot") - settingCmd.StringVar(&tgbotRuntime, "tgbotRuntime", "", "Set cron time for Telegram bot notifications") - settingCmd.StringVar(&tgbotchatid, "tgbotchatid", "", "Set chat ID for Telegram bot notifications") - settingCmd.BoolVar(&enabletgbot, "enabletgbot", false, "Enable notifications via Telegram bot") oldUsage := flag.Usage flag.Usage = func() { @@ -740,12 +667,6 @@ func main() { if getApiToken { GetApiToken(getApiToken) } - if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") { - updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime) - } - if enabletgbot { - updateTgbotEnableSts(enabletgbot) - } case "cert": err := settingCmd.Parse(os.Args[2:]) if err != nil {