mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-07 05:14:27 +00:00
41645255f1
* refactor(service): split client.go into focused files
client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):
client.go foundation: ClientService, ClientWithAttachments,
ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
client_locks.go inbound mutation locks, delete tombstones, compactOrphans
client_lookup.go read-only lookups (GetByID, List, EffectiveFlow, ...)
client_link.go inbound association sync (SyncInbound, DetachInbound, ...)
client_crud.go single-client CRUD + validation + protocol defaults
client_inbound_apply.go low-level inbound-settings mutators + by-email setters
client_bulk.go bulk attach/detach/adjust/delete/create + DelDepleted
client_traffic.go traffic-reset paths
client_groups.go client group management
client_paging.go paged listing, filtering, sorting, summary
Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.
* refactor(service): split inbound.go into focused files
inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):
inbound.go core inbound CRUD + InboundService (keeps pkg doc)
inbound_protocol.go protocol / stream capability helpers
inbound_node.go node/runtime/remote coordination + online tracking
inbound_traffic.go traffic accounting, reset, client stats
inbound_client_ips.go per-client IP tracking
inbound_clients.go client lookups within inbounds + copy-clients
inbound_disable.go auto-disable invalid inbounds/clients
inbound_migration.go DB migrations
inbound_sublink.go subscription link providers
inbound_util.go generic slice/string helpers
Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.
* refactor(service): split tgbot.go into focused files
tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):
tgbot.go lifecycle, bot setup, caches, small utils
tgbot_router.go incoming update / command / callback dispatch
tgbot_send.go outbound messaging primitives
tgbot_client.go client views, actions, subscription links
tgbot_inbound.go inbound listing / pickers
tgbot_report.go server usage, exhausted, online, backups, notifications
Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.
* refactor(client): dedupe single-field by-email setters
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.
Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.
SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.
* refactor(service): extract panel/ subpackage
Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):
user.go UserService (auth / 2FA / LDAP)
panel.go PanelService (restart / self-update) + version helpers
panel_other.go non-unix RestartPanel
panel_unix.go unix RestartPanel
api_token.go ApiTokenService
websocket.go WebSocketService
panel_test.go version/shellQuote unit tests
These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract integration/ subpackage
Move the external-provider integration leaves into web/service/integration/
(package integration):
warp.go WarpService (Cloudflare WARP)
nord.go NordService (NordVPN)
custom_geo.go CustomGeoService (custom geo asset management)
*_test.go custom_geo / panel-proxy tests
These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract tgbot/ subpackage
Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.
To support the package boundary without changing behavior:
- core exposes XrayProcess() *xray.Process so tgbot keeps calling the
exact same running-process methods it used via the package-level `p`;
- three core methods tgbot calls are exported: ClientService.checkIs-
EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
GetAllEmails (callers updated in-package);
- tgbot's embedded-field types and the few core type refs (Status,
ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.
Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.
* refactor(service): extract outbound/ subpackage
OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.
The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.
* refactor(util): move wireguard into its own subpackage
util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.
* refactor(sub): drop redundant sub prefix from filenames
Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:
subController.go -> controller.go
subService.go -> service.go
subClashService.go -> clash_service.go
subJsonService.go -> json_service.go
(+ matching _test.go files)
* refactor(controller): rename xui.go -> spa.go
XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".
* refactor: move backend packages under internal/
Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.
Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.
Couplings the Go compiler can't see, updated to the new layout:
- frontend i18n imports of web/translation (react.ts, setup.components.ts)
- vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
- Dockerfile COPY paths for web/dist and web/translation
- locale.go os.DirFS("web") disk fallback -> "internal/web"
- .gitignore and ci.yml go:embed stub for internal/web/dist
- api_docs_test.go repo-root relative walk (one level deeper)
- tools/openapigen filesystem package paths; ApiTokenView repointed to the
web/service/panel subpackage and codegen regenerated (clears a stale
type the ci.yml codegen check was failing on)
Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.
* fix(config): keep test runs from writing logs into the source tree
GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).
Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.
* docs: move subscription-template guide out of root into docs/
sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.
Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.
* fix: update stale web/ path references after the internal/ move
The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.
Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.
Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.
* refactor(web): remove the legacy /xui -> /panel redirect middleware
RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.
* build: fix .dockerignore for internal/ layout and exclude runtime dir
- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
so the stale exclude no longer matched and the locally-built dist could be
sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
binaries, certs — ~150MB) was being shipped into the build context for no
reason. Verified the pattern excludes only the directory and still keeps
x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
784 lines
25 KiB
Go
784 lines
25 KiB
Go
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")
|
||
b.WriteString(fmt.Sprintf("📧 Email: `%s`\r\n", client_Email))
|
||
b.WriteString(fmt.Sprintf("🔗 Attached: %s\r\n", attached))
|
||
b.WriteString(fmt.Sprintf("📊 Traffic: %s\r\n", traffic))
|
||
b.WriteString(fmt.Sprintf("📅 Expire: %s\r\n", expiry))
|
||
b.WriteString(fmt.Sprintf("🔢 IP limit: %s\r\n", ipLimit))
|
||
b.WriteString(fmt.Sprintf("👤 TG user: %s\r\n", tgID))
|
||
b.WriteString(fmt.Sprintf("💬 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<code>" + subURL + "</code>"
|
||
if subJsonURL != "" {
|
||
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||
}
|
||
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.NewRequest("GET", 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 <code>
|
||
msg.WriteString("<code>")
|
||
msg.WriteString(link)
|
||
msg.WriteString("</code>\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.NewRequest("GET", 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 service.XrayProcess().IsRunning() {
|
||
if slices.Contains(service.XrayProcess().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)
|
||
}
|
||
}
|