From 15fee2a035baa6251f297f0dfafc1a03a8efdaf6 Mon Sep 17 00:00:00 2001 From: DIMFLIX Date: Sat, 24 Oct 2026 13:39:56 +0300 Subject: [PATCH] feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760) The bot answered every action with a new message, so a chat filled with stale menus whose buttons still acted, and long client or inbound lists were paged through dozens of buttons. Each chat now has one live screen that is edited in place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists open through inline search, the add-client flow starts from a draft card, QR codes arrive as one labelled sheet, and repeated notices collapse into one with a counter. A client's /start and /usage show only that client's own links and usage, groups are refused, and the settings page warns when the bot's inline mode is off. Admin topics, the indexed Telegram lookup and every locale, Slovak included, carry over. --- .../docs/en/reference/api/settings.mdx | 9 +- docs/public/openapi.json | 70 ++ frontend/public/openapi.json | 70 ++ frontend/src/generated/examples.ts | 6 + frontend/src/generated/schemas.ts | 29 + frontend/src/generated/types.ts | 7 + frontend/src/generated/zod.ts | 8 + frontend/src/pages/api-docs/endpoints.ts | 7 + frontend/src/pages/settings/TelegramTab.tsx | 65 +- internal/web/controller/setting.go | 37 + internal/web/service/tgbot/helpers.go | 22 + internal/web/service/tgbot/i18n_keys_test.go | 131 ++ internal/web/service/tgbot/notices.go | 166 +++ internal/web/service/tgbot/notices_test.go | 156 +++ internal/web/service/tgbot/screen.go | 477 +++++++ .../web/service/tgbot/screen_client_qr.go | 136 ++ .../web/service/tgbot/screen_client_views.go | 383 ++++++ internal/web/service/tgbot/screen_inline.go | 598 +++++++++ .../web/service/tgbot/screen_inline_test.go | 620 +++++++++ internal/web/service/tgbot/screen_legacy.go | 146 +++ internal/web/service/tgbot/screen_view.go | 112 ++ internal/web/service/tgbot/screen_views.go | 338 +++++ internal/web/service/tgbot/screens_art.go | 179 +++ internal/web/service/tgbot/screens_test.go | 1117 +++++++++++++++++ internal/web/service/tgbot/tgbot.go | 29 +- .../tgbot/tgbot_add_client_picker_test.go | 64 - .../tgbot/tgbot_add_client_wizard_test.go | 138 ++ internal/web/service/tgbot/tgbot_broadcast.go | 197 ++- .../web/service/tgbot/tgbot_broadcast_test.go | 127 +- internal/web/service/tgbot/tgbot_client.go | 317 +---- .../tgbot/tgbot_client_draft_per_chat_test.go | 21 +- .../tgbot/tgbot_client_links_authz_test.go | 20 +- .../service/tgbot/tgbot_delete_after_test.go | 18 - internal/web/service/tgbot/tgbot_event.go | 31 +- .../web/service/tgbot/tgbot_groups_test.go | 130 ++ internal/web/service/tgbot/tgbot_inbound.go | 174 +-- .../tgbot/tgbot_individual_links_test.go | 20 +- internal/web/service/tgbot/tgbot_invite.go | 18 - internal/web/service/tgbot/tgbot_level.go | 2 +- internal/web/service/tgbot/tgbot_report.go | 387 ++---- .../tgbot/tgbot_report_batching_test.go | 56 +- .../service/tgbot/tgbot_review_fixes_test.go | 274 ++++ internal/web/service/tgbot/tgbot_router.go | 721 +++++------ internal/web/service/tgbot/tgbot_send.go | 148 +-- internal/web/service/tgbot/tgbot_send_test.go | 8 +- .../service/tgbot/tgbot_stale_button_test.go | 20 +- internal/web/translation/ar-EG.json | 113 +- internal/web/translation/en-US.json | 113 +- internal/web/translation/es-ES.json | 113 +- internal/web/translation/fa-IR.json | 113 +- internal/web/translation/id-ID.json | 113 +- internal/web/translation/ja-JP.json | 113 +- internal/web/translation/pt-BR.json | 113 +- internal/web/translation/ru-RU.json | 97 +- internal/web/translation/sk-SK.json | 115 +- internal/web/translation/tr-TR.json | 113 +- internal/web/translation/uk-UA.json | 113 +- internal/web/translation/vi-VN.json | 113 +- internal/web/translation/zh-CN.json | 113 +- internal/web/translation/zh-TW.json | 113 +- internal/web/web.go | 13 + tools/openapigen/main.go | 4 + 62 files changed, 7449 insertions(+), 1945 deletions(-) create mode 100644 internal/web/service/tgbot/helpers.go create mode 100644 internal/web/service/tgbot/i18n_keys_test.go create mode 100644 internal/web/service/tgbot/notices.go create mode 100644 internal/web/service/tgbot/notices_test.go create mode 100644 internal/web/service/tgbot/screen.go create mode 100644 internal/web/service/tgbot/screen_client_qr.go create mode 100644 internal/web/service/tgbot/screen_client_views.go create mode 100644 internal/web/service/tgbot/screen_inline.go create mode 100644 internal/web/service/tgbot/screen_inline_test.go create mode 100644 internal/web/service/tgbot/screen_legacy.go create mode 100644 internal/web/service/tgbot/screen_view.go create mode 100644 internal/web/service/tgbot/screen_views.go create mode 100644 internal/web/service/tgbot/screens_art.go create mode 100644 internal/web/service/tgbot/screens_test.go delete mode 100644 internal/web/service/tgbot/tgbot_add_client_picker_test.go create mode 100644 internal/web/service/tgbot/tgbot_add_client_wizard_test.go delete mode 100644 internal/web/service/tgbot/tgbot_delete_after_test.go create mode 100644 internal/web/service/tgbot/tgbot_groups_test.go create mode 100644 internal/web/service/tgbot/tgbot_review_fixes_test.go diff --git a/docs/content/docs/en/reference/api/settings.mdx b/docs/content/docs/en/reference/api/settings.mdx index df46620ec..525d19711 100644 --- a/docs/content/docs/en/reference/api/settings.mdx +++ b/docs/content/docs/en/reference/api/settings.mdx @@ -48,6 +48,10 @@ _openapi: title: Test Telegram bot connection by sending a test message to the configured chat. url: '#test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat' + - depth: 2 + title: Report what the configured bot can do. Inline mode is a BotFather switch, + so the panel warns when it is off. + url: '#report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off' - depth: 2 title: Test Discord bot connection by sending a test embed to the configured channel. @@ -90,6 +94,9 @@ _openapi: - content: Test Telegram bot connection by sending a test message to the configured chat. id: test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat + - content: Report what the configured bot can do. Inline mode is a BotFather + switch, so the panel warns when it is off. + id: report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off - content: Test Discord bot connection by sending a test embed to the configured channel. id: test-discord-bot-connection-by-sending-a-test-embed-to-the-configured-channel @@ -106,7 +113,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index f7ea3aa0d..d34729d6e 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -4727,6 +4727,35 @@ ], "type": "object" }, + "TgBotCapabilities": { + "description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.", + "properties": { + "groupPrivacy": { + "description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.", + "example": false, + "type": "boolean" + }, + "inlineEnabled": { + "example": true, + "type": "boolean" + }, + "running": { + "example": true, + "type": "boolean" + }, + "username": { + "example": "xui_bot", + "type": "string" + } + }, + "required": [ + "groupPrivacy", + "inlineEnabled", + "running", + "username" + ], + "type": "object" + }, "Traffic": { "description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.", "properties": { @@ -13360,6 +13389,47 @@ } } }, + "/panel/api/setting/tgBotCapabilities": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.", + "operationId": "post_panel_api_setting_tgBotCapabilities", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/TgBotCapabilities" + } + } + }, + "example": { + "success": true, + "obj": { + "groupPrivacy": false, + "inlineEnabled": true, + "running": true, + "username": "xui_bot" + } + } + } + } + } + } + } + }, "/panel/api/setting/testDiscord": { "post": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index f7ea3aa0d..d34729d6e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -4727,6 +4727,35 @@ ], "type": "object" }, + "TgBotCapabilities": { + "description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.", + "properties": { + "groupPrivacy": { + "description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.", + "example": false, + "type": "boolean" + }, + "inlineEnabled": { + "example": true, + "type": "boolean" + }, + "running": { + "example": true, + "type": "boolean" + }, + "username": { + "example": "xui_bot", + "type": "string" + } + }, + "required": [ + "groupPrivacy", + "inlineEnabled", + "running", + "username" + ], + "type": "object" + }, "Traffic": { "description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.", "properties": { @@ -13360,6 +13389,47 @@ } } }, + "/panel/api/setting/tgBotCapabilities": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.", + "operationId": "post_panel_api_setting_tgBotCapabilities", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/TgBotCapabilities" + } + } + }, + "example": { + "success": true, + "obj": { + "groupPrivacy": false, + "inlineEnabled": true, + "running": true, + "username": "xui_bot" + } + } + } + } + } + } + } + }, "/panel/api/setting/testDiscord": { "post": { "tags": [ diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index c839263cc..25b586539 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -1168,6 +1168,12 @@ export const EXAMPLES: Record = { "strategy": "random", "updatedAt": 1710000000000 }, + "TgBotCapabilities": { + "groupPrivacy": false, + "inlineEnabled": true, + "running": true, + "username": "xui_bot" + }, "Traffic": { "Down": 2097152, "IsInbound": true, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 233e62ef3..070b32d8c 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -4701,6 +4701,35 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "TgBotCapabilities": { + "description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.", + "properties": { + "groupPrivacy": { + "description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.", + "example": false, + "type": "boolean" + }, + "inlineEnabled": { + "example": true, + "type": "boolean" + }, + "running": { + "example": true, + "type": "boolean" + }, + "username": { + "example": "xui_bot", + "type": "string" + } + }, + "required": [ + "groupPrivacy", + "inlineEnabled", + "running", + "username" + ], + "type": "object" + }, "Traffic": { "description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index ecf17ef1f..5eaff4592 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -1066,6 +1066,13 @@ export interface SubBalancer { updatedAt: number; } +export interface TgBotCapabilities { + groupPrivacy: boolean; + inlineEnabled: boolean; + running: boolean; + username: string; +} + export interface Traffic { Down: number; IsInbound: boolean; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 921a6a0cd..cce59a990 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -1132,6 +1132,14 @@ export const SubBalancerSchema = z.object({ }); export type SubBalancer = z.infer; +export const TgBotCapabilitiesSchema = z.object({ + groupPrivacy: z.boolean(), + inlineEnabled: z.boolean(), + running: z.boolean(), + username: z.string(), +}); +export type TgBotCapabilities = z.infer; + export const TrafficSchema = z.object({ Down: z.number().int(), IsInbound: z.boolean(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 328ac75f5..f59fb9d40 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -2016,6 +2016,13 @@ export const sections: readonly Section[] = [ summary: 'Test Telegram bot connection by sending a test message to the configured chat.', response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}', }, + { + method: 'POST', + path: '/panel/api/setting/tgBotCapabilities', + summary: + 'Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.', + responseSchema: 'TgBotCapabilities', + }, { method: 'POST', path: '/panel/api/setting/testDiscord', diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx index 017cc502d..a6cab386b 100644 --- a/frontend/src/pages/settings/TelegramTab.tsx +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Button, Input, Select, Space, Switch, Tabs } from 'antd'; -import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; +import { BellOutlined, SendOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons'; import { HttpUtil, LanguageManager } from '@/utils'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; @@ -21,6 +21,34 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr const { isMobile } = useMediaQuery(); const [testLoading, setTestLoading] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null); + const [inlineWarning, setInlineWarning] = useState(null); + const [privacyWarning, setPrivacyWarning] = useState(null); + const [capsLoading, setCapsLoading] = useState(false); + + // The bot's lists live in inline mode, which only BotFather can enable, so the + // panel checks the live capability and warns instead of leaving dead buttons. + async function checkInlineMode() { + setCapsLoading(true); + try { + const res = (await HttpUtil.post('/panel/api/setting/tgBotCapabilities')) as { + success?: boolean; + obj?: { inlineEnabled?: boolean; groupPrivacy?: boolean; username?: string }; + }; + if (res.success && res.obj && !res.obj.inlineEnabled) { + setInlineWarning(res.obj.username || ''); + } else { + setInlineWarning(null); + } + // Privacy mode is the mirror-image trap: with it on, a bare /start typed + // in a group never reaches the bot, so the command looks dead. + setPrivacyWarning(res.success && !!res.obj?.groupPrivacy ? res.obj?.username || '' : null); + } catch { + // The bot may simply be stopped; the token field still tells that story. + setInlineWarning(null); + } finally { + setCapsLoading(false); + } + } async function handleTestTgBot() { setTestLoading(true); @@ -134,12 +162,43 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr /> + {inlineWarning !== null && ( + } + style={{ marginTop: 16 }} + title={t('pages.settings.tgInlineMissing')} + description={t('pages.settings.tgInlineMissingDesc')} + action={ + + } + /> + )} + + {privacyWarning !== null && ( + } + style={{ marginTop: 16 }} + title={t('pages.settings.tgPrivacyOn')} + description={t('pages.settings.tgPrivacyOnDesc')} + /> + )} + diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 07cb06bc4..61172e3be 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -80,6 +80,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled) g.POST("/testSmtp", a.testSmtp) g.POST("/testTgBot", a.testTgBot) + g.POST("/tgBotCapabilities", a.tgBotCapabilities) g.POST("/testDiscord", a.testDiscord) } @@ -348,6 +349,42 @@ func (a *SettingController) testTgBot(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started")) } +// tgBotCapabilities reports what the bot can do. Inline mode is a BotFather +// switch the API can read but never set, so the panel warns when lists can't open. +func (a *SettingController) tgBotCapabilities(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 + } + if tgCapsFunc == nil { + jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started")) + return + } + caps, err := tgCapsFunc() + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err) + return + } + jsonObj(c, caps, nil) +} + +// TgBotCapabilities is the browser-safe shape of the bot's own capabilities. +type TgBotCapabilities struct { + InlineEnabled bool `json:"inlineEnabled" example:"true"` + // GroupPrivacy off means the bot reads every group message; when it is on, + // a bare /start in a group is invisible to the bot. + GroupPrivacy bool `json:"groupPrivacy" example:"false"` + Username string `json:"username" example:"xui_bot"` + Running bool `json:"running" example:"true"` +} + +// tgCapsFunc is wired from the web layer; importing tgbot here is circular. +var tgCapsFunc func() (TgBotCapabilities, error) + +// SetTgBotCapsFunc registers the capability probe. +func SetTgBotCapsFunc(fn func() (TgBotCapabilities, error)) { tgCapsFunc = fn } + // testTgFunc is set from web layer to test Telegram sending without circular imports. var testTgFunc func() error diff --git a/internal/web/service/tgbot/helpers.go b/internal/web/service/tgbot/helpers.go new file mode 100644 index 000000000..596d3d999 --- /dev/null +++ b/internal/web/service/tgbot/helpers.go @@ -0,0 +1,22 @@ +package tgbot + +// helpers.go holds the small shared primitives the screen layer needs, so the +// router never grows a second copy of them. +import ( + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// xrayProcessOrNil returns the running Xray process, or nil; a panel whose Xray +// is down is a normal state the screens must render, not an error. +func xrayProcessOrNil() *xray.Process { + process := service.XrayProcess() + if process == nil || !process.IsRunning() { + return nil + } + return process +} + +// panelVersion is the version string the menu shows. +func panelVersion() string { return config.GetPanelVersion() } diff --git a/internal/web/service/tgbot/i18n_keys_test.go b/internal/web/service/tgbot/i18n_keys_test.go new file mode 100644 index 000000000..627c7233f --- /dev/null +++ b/internal/web/service/tgbot/i18n_keys_test.go @@ -0,0 +1,131 @@ +package tgbot + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// I18nBot returns "" for a missing key, so a screen renders blank buttons without failing; that is how +// /start once answered with no menu. Every key a Go source asks for must exist. + +var ( + i18nBotKeyPattern = regexp.MustCompile(`I18nBot\(\s*"([^"]+)"`) + // Button labels go through btn(), which also calls I18nBot internally, so a + // broken button key is invisible to a scan of I18nBot calls alone. + btnKeyPattern = regexp.MustCompile(`\.btn(?:2)?\(\s*"([^"]+)"`) + // The category screen asks for "tgbot.messages.category_" + name. + categoryKeyPattern = regexp.MustCompile(`tgbot\.messages\.category_"`) +) + +func TestEveryBotKeyExistsInTheTranslations(t *testing.T) { + root := repoRootForTest(t) + keys := map[string]string{} // key -> first file that asks for it + err := filepath.Walk(filepath.Join(root, "internal"), func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") { + return err + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + patterns := []*regexp.Regexp{i18nBotKeyPattern, btnKeyPattern} + for _, pattern := range patterns { + for _, m := range pattern.FindAllStringSubmatch(string(data), -1) { + key := m[1] + // Keys are also built at run time ("tgbot.screens." + kind); a literal ending in a separator is such + // a prefix, and the set it expands to is checked explicitly. + if strings.ContainsAny(key, "%$") || strings.HasSuffix(key, ".") || strings.HasSuffix(key, "_") { + continue + } + if _, seen := keys[key]; !seen { + keys[key] = strings.TrimPrefix(path, root+"/") + } + } + } + if categoryKeyPattern.MatchString(string(data)) { + for _, category := range []string{"server", "clients", "traffic", "maintenance"} { + keys["tgbot.messages.category_"+category] = strings.TrimPrefix(path, root+"/") + } + } + return nil + }) + if err != nil { + t.Fatalf("scan sources: %v", err) + } + + // Keys assembled from a prefix at run time; their full set is checked so the + // dynamic path cannot hide a missing entry. + for _, kind := range screenKinds { + keys["tgbot.screens."+kind] = "screens_art.go (screenKinds)" + } + + files, err := filepath.Glob(filepath.Join(root, "internal/web/translation/*.json")) + if err != nil || len(files) == 0 { + t.Fatalf("no translation files found: %v", err) + } + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + var bundle map[string]any + if err := json.Unmarshal(data, &bundle); err != nil { + t.Fatalf("parse %s: %v", file, err) + } + var missing []string + for key, source := range keys { + if !nestedKeyPresent(bundle, key) { + missing = append(missing, fmt.Sprintf("%s (%s)", key, source)) + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("%s is missing %d key(s) the bot asks for:\n %s", + filepath.Base(file), len(missing), strings.Join(missing, "\n ")) + } + } +} + +// nestedKeyPresent walks a dotted key through the nested translation objects. +func nestedKeyPresent(bundle map[string]any, key string) bool { + var current any = bundle + for _, part := range strings.Split(key, ".") { + obj, ok := current.(map[string]any) + if !ok { + return false + } + current, ok = obj[part] + if !ok { + return false + } + } + return true +} + +// repoRootForTest walks up from the test's working directory to the module root. +func repoRootForTest(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("module root not found") + } + dir = parent + } +} diff --git a/internal/web/service/tgbot/notices.go b/internal/web/service/tgbot/notices.go new file mode 100644 index 000000000..d290f291b --- /dev/null +++ b/internal/web/service/tgbot/notices.go @@ -0,0 +1,166 @@ +package tgbot + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// Panel notifications (outage, failed login, backup) are ONE live message per +// (chat, kind): repeats edit it in place instead of burying the chat. + +type noticeKey struct { + chatID int64 + kind string +} + +type noticeState struct { + msgID int + body string + count int + at time.Time +} + +type noticeStore struct { + mu sync.Mutex + notices map[noticeKey]noticeState +} + +var notices = ¬iceStore{notices: map[noticeKey]noticeState{}} + +func (s *noticeStore) get(key noticeKey) (noticeState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + st, ok := s.notices[key] + return st, ok +} + +func (s *noticeStore) put(key noticeKey, st noticeState) { + s.mu.Lock() + s.notices[key] = st + s.mu.Unlock() +} + +func (s *noticeStore) reset() { + s.mu.Lock() + s.notices = map[noticeKey]noticeState{} + s.mu.Unlock() +} + +// noticeRepeatWindow is how long a repeated event of the same kind keeps +// updating the live card instead of starting a new one. +const noticeRepeatWindow = 30 * time.Minute + +// liveNotice sends or updates the chat's card for one kind of event; a repeat +// inside the window edits it and counts, so a storm reads as one numbered line. +func (t *Tgbot) liveNotice(chatID int64, kind, body string) { + if liveBot() == nil { + return + } + key := noticeKey{chatID: chatID, kind: kind} + st, ok := notices.get(key) + + if ok && time.Since(st.at) < noticeRepeatWindow && st.msgID != 0 { + st.count++ + st.at = time.Now() + st.body = body + text := t.noticeText(body, st.count) + if t.editNotice(chatID, st.msgID, text) { + notices.put(key, st) + return + } + // The card is gone or too old to edit: fall through and post a fresh one. + } + + st = noticeState{body: body, count: 1, at: time.Now()} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + sc, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: t.noticeText(body, 1), ParseMode: "HTML", ReplyMarkup: t.hideButton(), + }) + if err != nil { + logger.Warning("Failed to send a notice:", err) + return + } + st.msgID = sc.MessageID + notices.put(key, st) +} + +// noticeText renders the event plus its repeat counter. +func (t *Tgbot) noticeText(body string, count int) string { + text := body + if count > 1 { + text += t.I18nBot("tgbot.notices.repeat", "Count=="+itoa(count), "Time=="+time.Now().Format("15:04:05")) + } + return text +} + +// editNotice rewrites a live card. false means it could not be edited and the +// caller must post a new one. +func (t *Tgbot) editNotice(chatID int64, msgID int, text string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, err := liveBot().EditMessageText(ctx, &telego.EditMessageTextParams{ + ChatID: tu.ID(chatID), MessageID: msgID, Text: text, ParseMode: "HTML", + ReplyMarkup: t.hideButton(), + }) + if err == nil || isTelegramNotModifiedError(err) { + return true + } + logger.Debug("Notice not editable, posting a new one:", err) + return false +} + +// dropNoticeFor forgets the card a hidden message belonged to, so the next +// event of that kind starts a fresh card. +func (t *Tgbot) dropNoticeFor(chatID int64, msgID int) { + notices.mu.Lock() + defer notices.mu.Unlock() + for key, st := range notices.notices { + if key.chatID == chatID && st.msgID == msgID { + delete(notices.notices, key) + } + } +} + +// sendDocumentWithCaption uploads a file with its summary as caption: a document +// cannot hold a screen, so one message does the work of two. +func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption string) bool { + if liveBot() == nil || data == nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + _, err := liveBot().SendDocument(ctx, &telego.SendDocumentParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), + Document: tu.FileFromBytes(data, name), + Caption: caption, + ParseMode: "HTML", + ReplyMarkup: t.hideButton(), + }) + if err != nil { + logger.Warning("Failed to upload a file with a caption:", err) + return false + } + return true +} + +// trimCaption cuts a summary down to what a caption can carry, on a line break +// so a sentence is never left half-written. +func trimCaption(text string, limit int) string { + if limit <= 0 || len([]rune(text)) <= limit { + return text + } + runes := []rune(text) + cut := string(runes[:limit]) + if idx := strings.LastIndex(cut, "\n"); idx > 0 { + cut = cut[:idx] + } + return strings.TrimRight(cut, "\r\n") +} diff --git a/internal/web/service/tgbot/notices_test.go b/internal/web/service/tgbot/notices_test.go new file mode 100644 index 000000000..1ba5faa05 --- /dev/null +++ b/internal/web/service/tgbot/notices_test.go @@ -0,0 +1,156 @@ +package tgbot + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" +) + +// noticeServer records sendMessage/sendDocument/editMessageText with the visible body, so a test can +// assert how many messages an event burst produced. +func noticeServer(t *testing.T) (*httptest.Server, func() []apiCall) { + t.Helper() + var mu sync.Mutex + var calls []apiCall + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + sent := extractSentBody(t, r) + mu.Lock() + calls = append(calls, apiCall{Method: method, Payload: map[string]any{ + "text": sent.Text, "chat_id": float64(sent.ChatID), + }}) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch method { + case "sendMessage", "sendDocument", "sendPhoto": + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"}, + }}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"}, + }}) + } + })) + return srv, func() []apiCall { + mu.Lock() + defer mu.Unlock() + return append([]apiCall(nil), calls...) + } +} + +// Regression test: the same event twice must edit its live card, not post a +// second message — a flapping outbound used to bury the chat. +func TestRepeatedEventEditsItsLiveCard(t *testing.T) { + srv, calls := noticeServer(t) + swapTestBot(t, srv.URL) + defer srv.Close() + setTestAdmins(t, 1) + notices.reset() + + tb := initReportDB(t) + event := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "nl-node"} + + tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN") + tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN") + tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN") + + got := calls() + if n := countMethod(got, "sendMessage"); n != 1 { + t.Errorf("sendMessage = %d, want 1: repeats must edit, not post (calls: %v)", n, methods(got)) + } + if n := countMethod(got, "editMessageText"); n != 2 { + t.Errorf("editMessageText = %d, want 2: each repeat redraws the card", n) + } + _ = event +} + +// A different kind of event gets its own card: they tell unrelated stories. +func TestDifferentEventKindsKeepSeparateCards(t *testing.T) { + srv, calls := noticeServer(t) + swapTestBot(t, srv.URL) + defer srv.Close() + setTestAdmins(t, 1) + notices.reset() + + tb := initReportDB(t) + tb.liveNotice(1, "outbound_down:a", "A is DOWN") + tb.liveNotice(1, "xray_crash:x", "Xray crashed") + + if n := countMethod(calls(), "sendMessage"); n != 2 { + t.Errorf("sendMessage = %d, want 2: unrelated events need their own cards", n) + } +} + +// The backup must arrive as one message: the file carries the summary. +func TestBackupArrivesAsOneMessageWithACaption(t *testing.T) { + srv, calls := noticeServer(t) + swapTestBot(t, srv.URL) + defer srv.Close() + notices.reset() + + tb := initReportDB(t) + tb.sendDocumentWithCaption(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time") + + got := calls() + sends := 0 + for _, c := range got { + if c.Method == "sendMessage" || c.Method == "sendDocument" { + sends++ + } + } + if sends != 1 { + t.Fatalf("messages = %d, want 1 (calls: %v)", sends, methods(got)) + } + if countMethod(got, "sendDocument") != 1 { + t.Error("the backup did not travel as a document") + } +} + +// A caption longer than Telegram's limit is cut on a line break, never mid-line. +func TestTrimCaptionCutsOnALineBreak(t *testing.T) { + body := strings.Repeat("line of a long report\n", 200) + got := trimCaption(body, botCaptionLimit) + if len([]rune(got)) > botCaptionLimit { + t.Fatalf("caption is %d runes, want at most %d", len([]rune(got)), botCaptionLimit) + } + if strings.HasSuffix(got, "\n") || strings.HasSuffix(got, "\r") { + t.Errorf("caption ends on a dangling line break: %q", got[max(0, len(got)-20):]) + } +} + +// Hiding a notice forgets its card, so the next event does not edit a message +// the user just deleted. +func TestHiddenNoticeIsForgotten(t *testing.T) { + srv, _ := noticeServer(t) + swapTestBot(t, srv.URL) + defer srv.Close() + setTestAdmins(t, 1) + notices.reset() + + tb := initReportDB(t) + tb.liveNotice(1, "outbound_down:a", "A is DOWN") + key := noticeKey{chatID: 1, kind: "outbound_down:a"} + if _, ok := notices.get(key); !ok { + t.Fatal("the live card was not remembered") + } + tb.dropNoticeFor(1, 77) + if _, ok := notices.get(key); ok { + t.Error("a hidden notice is still remembered; the next event would edit a deleted message") + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +var _ = io.Discard diff --git a/internal/web/service/tgbot/screen.go b/internal/web/service/tgbot/screen.go new file mode 100644 index 000000000..18ddc3563 --- /dev/null +++ b/internal/web/service/tgbot/screen.go @@ -0,0 +1,477 @@ +package tgbot + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// botCaptionLimit is Telegram's caption ceiling; a longer caption is a hard API +// error, so a screen pages its body instead of risking a lost render. +const botCaptionLimit = 1024 + +// screen is the bot's only chat surface: one photo message whose caption and +// keyboard are edited on every action, so a chat never grows a second menu. +type screen struct { + msgID int + kind string + // root marks a screen that IS the viewer's top level: it must not offer a + // back button, because there is nothing above it to go back to. + root bool + // qrPicture is the sheet of QR codes a "qr" screen shows instead of the + // shared avatar: a code belongs to one client, so it is rendered per screen. + qrPicture *telego.InputFile + // qrSource is the subscription URL the sheet was built from. + qrSource string + // attachment is a picture built for this one screen (a QR). It is uploaded + // on send and remembered by content, so an edit reuses the same picture. + attachment *telego.InputFile + photo string // file_id to reuse for an edit; empty means upload or text + hasPhoto bool // whether the tracked message carries a picture + header string + body string + pages []string + page int + rows [][]telego.InlineKeyboardButton + markup *telego.InlineKeyboardMarkup + text string + pageMax int +} + +// screenStore keeps the live screen per chat. The msgID inside it belongs to +// that chat, so a stale keyboard cannot address another chat's screen. +type screenStore struct { + mu sync.Mutex + chats map[int64]*screen +} + +var screens = &screenStore{chats: map[int64]*screen{}} + +func (s *screenStore) get(chatID int64) (*screen, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sc, ok := s.chats[chatID] + return sc, ok +} + +func (s *screenStore) put(chatID int64, sc *screen) { + s.mu.Lock() + s.chats[chatID] = sc + s.mu.Unlock() +} + +func (s *screenStore) clear(chatID int64) { + s.mu.Lock() + delete(s.chats, chatID) + s.mu.Unlock() +} + +func (s *screenStore) reset() { + s.mu.Lock() + s.chats = map[int64]*screen{} + s.mu.Unlock() +} + +// newScreen assembles a screen: breadcrumb header, body paged into +// caption-sized chunks, and the caller's keyboard rows. + +// rootScreen builds the viewer's own top level, so the way-out invariant does +// not apply: a back button there would lead to the same screen. +func (t *Tgbot) rootScreen(kind, body string, rows ...[]telego.InlineKeyboardButton) *screen { + sc := t.newScreen(kind, body, append(rows, t.backRow())...) + sc.root = true + sc.rows = stripWayOut(sc.rows) + if sc.markup != nil { + sc.markup = tu.InlineKeyboard(sc.rows...) + } + return sc +} + +// stripWayOut removes the home row. Used only when building a root screen, where +// a way out would be a link to itself. +func stripWayOut(rows [][]telego.InlineKeyboardButton) [][]telego.InlineKeyboardButton { + out := make([][]telego.InlineKeyboardButton, 0, len(rows)) + for _, row := range rows { + kept := make([]telego.InlineKeyboardButton, 0, len(row)) + for _, btn := range row { + if btn.CallbackData != cbHome { + kept = append(kept, btn) + } + } + if len(kept) > 0 { + out = append(out, kept) + } + } + return out +} + +func (t *Tgbot) newScreen(kind, body string, rows ...[]telego.InlineKeyboardButton) *screen { + // One header builder for every screen, so none can open as a dead end. + // Terminal callbacks beyond "home" are registered by the caller. + rows = t.ensureWayOut(rows) + header := "" + if crumb := t.breadcrumb(kind); crumb != "" { + header = "" + crumb + "\r\n" + } + // Bodies arrive with either line ending; paging splits on CRLF, so a body + // built with bare LF would silently stay one over-long page. + body = strings.ReplaceAll(body, "\r\n", "\n") + body = strings.ReplaceAll(body, "\n", "\r\n") + pages := pageMessage(body, botCaptionLimit-len(header)) + if len(pages) == 0 { + pages = []string{""} + } + return &screen{kind: kind, header: header, body: body, pages: pages, rows: rows, pageMax: len(pages)} +} + +// renderScreen is the single entry point for drawing a screen: EDIT first, and +// replace (send then delete) only when the edit is genuinely impossible. +func (t *Tgbot) renderScreen(chatID int64, sc *screen) { + if sc == nil || liveBot() == nil { + return + } + // A QR screen carries its own picture: the codes being shown are what the + // screen was opened for, and a file beside it is just clutter. + if sc.qrPicture != nil { + sc.hasPhoto, sc.attachment = true, sc.qrPicture + } else { + t.attachArt(sc) + } + sc.text = sc.header + sc.pages[min(sc.page, len(sc.pages)-1)] + rows := sc.rows + if row := t.pagerRow(sc); row != nil { + rows = append(append([][]telego.InlineKeyboardButton{}, sc.rows...), row) + } + sc.markup = tu.InlineKeyboard(rows...) + + if prev, ok := t.screens().get(chatID); ok && prev.msgID != 0 { + if t.editScreen(chatID, prev, sc) { + sc.msgID = prev.msgID + t.screens().put(chatID, sc) + return + } + } + t.replaceScreen(chatID, sc) +} + +// attachArt pins the screen's picture (avatar, else the always-available black +// tile), so a screen is always a photo message and edits can go through media. +func (t *Tgbot) attachArt(sc *screen) { + // A stale cache is refreshed off the render path, so a picture uploaded to + // @BotFather shows up without a panel restart and no screen waits on it. + t.ensureScreenArt() + sc.hasPhoto = true + if id, ok := artFileID(sc.kind); ok { + sc.photo = id + return + } + sc.photo = "" +} + +// screens keeps one call site for the store, so a test can swap it. +func (t *Tgbot) screens() *screenStore { return screens } + +// editScreen edits the tracked message into the new screen. false means the +// caller must replace it: the message is gone, too old, or of another type. +func (t *Tgbot) editScreen(chatID int64, prev *screen, next *screen) bool { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var err error + switch { + case next.hasPhoto && next.photo != "": + _, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{ + ChatID: tu.ID(chatID), + MessageID: prev.msgID, + Media: &telego.InputMediaPhoto{ + Type: "photo", Media: tu.FileFromID(next.photo), Caption: next.text, ParseMode: "HTML", + }, + ReplyMarkup: next.markup, + }) + case next.attachment != nil: + _, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{ + ChatID: tu.ID(chatID), + MessageID: prev.msgID, + Media: &telego.InputMediaPhoto{ + Type: "photo", Media: *next.attachment, Caption: next.text, ParseMode: "HTML", + }, + ReplyMarkup: next.markup, + }) + case next.hasPhoto: + // No file_id yet (the avatar was never read): upload the fallback tile. + upload, ok := artUpload(next.kind) + if !ok { + return false + } + _, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{ + ChatID: tu.ID(chatID), + MessageID: prev.msgID, + Media: &telego.InputMediaPhoto{ + Type: "photo", Media: upload, Caption: next.text, ParseMode: "HTML", + }, + ReplyMarkup: next.markup, + }) + case prev.hasPhoto: + // A photo message cannot become a text one: drop the picture and let + // its caption carry the screen. + _, err = liveBot().EditMessageCaption(ctx, &telego.EditMessageCaptionParams{ + ChatID: tu.ID(chatID), MessageID: prev.msgID, + Caption: next.text, ParseMode: "HTML", ReplyMarkup: next.markup, + }) + default: + _, err = liveBot().EditMessageText(ctx, &telego.EditMessageTextParams{ + ChatID: tu.ID(chatID), + MessageID: prev.msgID, + Text: next.text, + ParseMode: "HTML", + ReplyMarkup: next.markup, + }) + } + if err == nil || isTelegramNotModifiedError(err) { + return true + } + logger.Debug("Screen not editable, replacing:", err) + return false +} + +// replaceScreen sends the new screen BEFORE deleting the old one: with a slow +// send, delete-first leaves the chat empty and reads as broken. +func (t *Tgbot) replaceScreen(chatID int64, sc *screen) int { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + prev, hadPrev := t.screens().get(chatID) + var sent *telego.Message + var err error + switch { + case sc.hasPhoto && sc.photo != "": + sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: tu.FileFromID(sc.photo), + Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup, + }) + case sc.attachment != nil: + sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: *sc.attachment, + Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup, + }) + case sc.hasPhoto: + upload, ok := artUpload(sc.kind) + if !ok { + return 0 + } + sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: upload, + Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup, + }) + default: + sent, err = liveBot().SendMessage(ctx, &telego.SendMessageParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup, + }) + } + if err != nil { + logger.Warning("Failed to send a screen:", err) + return 0 + } + if hadPrev && prev.msgID != 0 { + t.deleteOwnMessage(chatID, prev.msgID) + } + sc.msgID = sent.MessageID + // Telegram hands back the file_id of the uploaded tile: remembering it keeps + // every later edit a cheap media edit instead of another upload. + if sc.hasPhoto && sc.photo == "" && len(sent.Photo) > 0 { + sc.photo = sent.Photo[len(sent.Photo)-1].FileID + cacheArtFileID(sc.kind, sc.photo) + } + t.screens().put(chatID, sc) + return sent.MessageID +} + +// ensureBackRow guarantees every screen can be left (a screen with only its own +// actions traps the user) and checks by callback, not label. + +// wayOutNotes are callbacks that already return to a root menu, so a screen +// carrying one must not gain a second, confusing back row. +var wayOutNotes = []string{cbHome} + +// terminalCallbacks extends wayOutNotes: an engine may register the callbacks +// its own root menu answers to. Guarded because tests and the panel install in. +var ( + terminalCallbacksMu sync.RWMutex + terminalCallbacks []string +) + +func registerTerminalCallbacks(data ...string) { + terminalCallbacksMu.Lock() + terminalCallbacks = append(terminalCallbacks, data...) + terminalCallbacksMu.Unlock() +} + +// hasWayOut reports whether any button on the screen is a route back to a root +// menu, so that a screen offering one is not a dead end. +func hasWayOut(rows [][]telego.InlineKeyboardButton) bool { + terminalCallbacksMu.RLock() + known := append(append([]string{}, wayOutNotes...), terminalCallbacks...) + terminalCallbacksMu.RUnlock() + for _, row := range rows { + for _, btn := range row { + for _, want := range known { + if btn.CallbackData == want { + return true + } + } + } + } + return false +} + +// ensureWayOut appends the home row to a screen that offers no exit. This is +// the one place screens are built, so no new screen can forget it. +func (t *Tgbot) ensureWayOut(rows [][]telego.InlineKeyboardButton) [][]telego.InlineKeyboardButton { + if hasWayOut(rows) { + return rows + } + return append(append([][]telego.InlineKeyboardButton{}, rows...), t.backRow()) +} + +// breadcrumb names the screen's place in the tree, so a user who was away can +// see where a stale keyboard left them. +func (t *Tgbot) breadcrumb(kind string) string { + key := "tgbot.screens." + kind + crumb := t.I18nBot(key) + if crumb == "" || crumb == key { + return "" + } + return t.I18nBot("tgbot.screens.panel") + " › " + crumb +} + +// pagerRow adds the page controls when the body did not fit one caption; the +// arrows are callback data, never a bare icon-only target. +func (t *Tgbot) pagerRow(sc *screen) []telego.InlineKeyboardButton { + if sc.pageMax <= 1 { + return nil + } + prev := tu.InlineKeyboardButton("‹").WithCallbackData("pg:prev") + if sc.page <= 0 { + prev = tu.InlineKeyboardButton("·").WithCallbackData("pg:none") + } + next := tu.InlineKeyboardButton("›").WithCallbackData("pg:next") + if sc.page >= sc.pageMax-1 { + next = tu.InlineKeyboardButton("·").WithCallbackData("pg:none") + } + return tu.InlineKeyboardRow(prev, tu.InlineKeyboardButton(t.pageLabel(sc.page, sc.pageMax)).WithCallbackData("pg:none"), next) +} + +// pageLabel renders "Стр. 2 из 5" through the locale, never a raw "2/5". +func (t *Tgbot) pageLabel(page, pages int) string { + return t.I18nBot("tgbot.buttons.page", "Page=="+itoa(page+1), "Pages=="+itoa(pages)) +} + +// showPage re-renders the stored screen at another page of its body. +func (t *Tgbot) showPage(chatID int64, page int) { + sc, ok := t.screens().get(chatID) + if !ok || sc.pageMax <= 1 || page < 0 || page >= sc.pageMax { + return + } + sc.page = page + sc.pages = pageMessage(sc.body, botCaptionLimit-len(sc.header)) + t.renderScreen(chatID, sc) +} + +// deleteScreenMessage is best effort: the user may have removed the screen, or +// Telegram may refuse past the 48-hour window. Neither is worth operator noise. +func (t *Tgbot) deleteScreenMessage(chatID int64, msgID int) { t.deleteOwnMessage(chatID, msgID) } + +// deleteIncoming removes a message the user sent: the wizard's text replies and +// the pilot taps must not pile up in the chat. +func (t *Tgbot) deleteIncoming(message *telego.Message) { + if message == nil || liveBot() == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := liveBot().DeleteMessage(ctx, &telego.DeleteMessageParams{ChatID: tu.ID(message.Chat.ID), MessageID: message.MessageID}); err != nil { + logger.Debug("Cannot delete a user message:", err) + } +} + +// deleteIncomingID removes one of the user's own messages by id, for flows that +// hold ids rather than message structs (the broadcast's composing messages). +func (t *Tgbot) deleteIncomingID(chatID int64, messageID int) { + if messageID == 0 { + return + } + t.deleteOwnMessage(chatID, messageID) +} + +// deleteOwnMessage removes one of our messages without surfacing a failure: the +// user may have deleted it, and Telegram refuses past 48 hours. +func (t *Tgbot) deleteOwnMessage(chatID int64, msgID int) { + if msgID == 0 || liveBot() == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := liveBot().DeleteMessage(ctx, &telego.DeleteMessageParams{ChatID: tu.ID(chatID), MessageID: msgID}); err != nil { + logger.Debug("Cannot delete a bot message:", err) + } +} + +// hideButton is the keyboard of a non-screen message: one tap clears it. +func (t *Tgbot) hideButton() *telego.InlineKeyboardMarkup { + return hideButtonMarkup() +} + +// hideButtonMarkup is the hide keyboard for callers with no Tgbot (e.g. a +// broadcast copy); the label falls back to English when the key cannot resolve. +func hideButtonMarkup() *telego.InlineKeyboardMarkup { + label := (&Tgbot{}).I18nBot("tgbot.buttons.hide") + if strings.TrimSpace(label) == "" { + label = "🙈 Hide" + } + return tu.InlineKeyboard(tu.InlineKeyboardRow( + tu.InlineKeyboardButton(label).WithCallbackData("hide"), + )) +} + +// answerSilent acknowledges a callback without a toast, for taps whose only +// effect is visible in the message itself. +func (t *Tgbot) answerSilent(callbackID string) { + if liveBot() == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := liveBot().AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil { + logger.Debug("Answer callback query failed:", err) + } +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// I18nBotStatic is gone; labels are built on the Tgbot receiver. diff --git a/internal/web/service/tgbot/screen_client_qr.go b/internal/web/service/tgbot/screen_client_qr.go new file mode 100644 index 000000000..ecce21b26 --- /dev/null +++ b/internal/web/service/tgbot/screen_client_qr.go @@ -0,0 +1,136 @@ +package tgbot + +import ( + "bytes" + "image" + "image/color" + "image/draw" + "image/png" + "strconv" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// The QR screen lays every code (subscription, JSON, up to five app links) on +// ONE picture instead of one document each — up to seven files before. + +// subscriptionQRCode is one code on the sheet, in the order it is drawn. +type subscriptionQRCode struct { + content string + // kind selects the legend line: "sub", "json" or "link". + kind string + // app numbers an app link in the legend. + app int +} + +// subscriptionQRCodes collects every code for a client in the order Telegram +// used to deliver them: subscription, JSON subscription, then the app links. +func (t *Tgbot) subscriptionQRCodes(email, subURL string) []subscriptionQRCode { + if subURL == "" { + return nil + } + codes := []subscriptionQRCode{{content: subURL, kind: "sub"}} + if _, subJSON, err := t.buildSubscriptionURLs(email); err == nil && subJSON != "" { + codes = append(codes, subscriptionQRCode{content: subJSON, kind: "json"}) + } + if links, err := t.clientSubLinks(email, subURL); err == nil { + for i, link := range links { + if i >= qrSheetMaxLinks { + break + } + codes = append(codes, subscriptionQRCode{content: link, kind: "link", app: i + 1}) + } + } + if len(codes) > qrSheetMaxCodes { + codes = codes[:qrSheetMaxCodes] + } + return codes +} + +const ( + qrTileSize = 320 + qrSheetPad = 24 + qrSheetMaxCols = 3 + // The sheet stays inside Telegram's photo size limits while covering what + // the original sent as separate documents. + qrSheetMaxCodes = 6 + qrSheetMaxLinks = 4 +) + +// qrCodeLegend numbers the codes in the picture's own order, so a code can be +// told apart without reading it: the tiles carry no text, they are scanned. +func (t *Tgbot) qrCodeLegend(codes []subscriptionQRCode) string { + if len(codes) == 0 { + return "" + } + var body bytes.Buffer + body.WriteString("\r\n") + body.WriteString(t.I18nBot("tgbot.messages.qrLegendHeader")) + body.WriteString("\r\n") + for i, code := range codes { + number := strconv.Itoa(i + 1) + switch code.kind { + case "json": + body.WriteString(t.I18nBot("tgbot.messages.qrLegendJSON", "Code=="+number)) + case "link": + body.WriteString(t.I18nBot("tgbot.messages.qrLegendLink", + "Code=="+number, "App=="+strconv.Itoa(code.app))) + default: + body.WriteString(t.I18nBot("tgbot.messages.qrLegendSubscription", "Code=="+number)) + } + body.WriteString("\r\n") + } + return body.String() +} + +// qrSheetPicture draws every code onto one picture in the legend's order and +// returns it as an uploadable file; nil means nothing could be rendered. +func qrSheetPicture(codes []subscriptionQRCode) *telego.InputFile { + tiles := make([]image.Image, 0, len(codes)) + for _, code := range codes { + codePNG, err := qrPNG(code.content, qrTileSize) + if err != nil { + logger.Warning("Failed to render a subscription QR:", err) + continue + } + img, err := png.Decode(bytes.NewReader(codePNG)) + if err != nil { + logger.Warning("Failed to decode a subscription QR:", err) + continue + } + tiles = append(tiles, img) + } + if len(tiles) == 0 { + return nil + } + + cols := len(tiles) + if cols > qrSheetMaxCols { + cols = qrSheetMaxCols + } + rows := (len(tiles) + cols - 1) / cols + width := cols*qrTileSize + (cols+1)*qrSheetPad + height := rows*qrTileSize + (rows+1)*qrSheetPad + + // The codes must keep their light background to stay scannable, so the sheet + // is white rather than the dark tone the screens use. + sheet := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.Draw(sheet, sheet.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) + + for i, tile := range tiles { + x := qrSheetPad + (i%cols)*(qrTileSize+qrSheetPad) + y := qrSheetPad + (i/cols)*(qrTileSize+qrSheetPad) + draw.Draw(sheet, image.Rect(x, y, x+qrTileSize, y+qrTileSize), tile, tile.Bounds().Min, draw.Src) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, sheet); err != nil { + logger.Warning("Failed to encode the QR sheet:", err) + return nil + } + file := tu.FileFromBytes(buf.Bytes(), "qr.png") + return &file +} diff --git a/internal/web/service/tgbot/screen_client_views.go b/internal/web/service/tgbot/screen_client_views.go new file mode 100644 index 000000000..3e82b1a01 --- /dev/null +++ b/internal/web/service/tgbot/screen_client_views.go @@ -0,0 +1,383 @@ +package tgbot + +import ( + "context" + "encoding/json" + "fmt" + "html" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + "github.com/skip2/go-qrcode" +) + +func qrPNG(content string, size int) ([]byte, error) { + if size <= 0 { + size = 256 + } + return qrcode.Encode(content, qrcode.Medium, size) +} + +// The client card and its screens (limits, expiry, IP log, TG binding, links, +// QR) all redraw the same tracked message, so none leaves the previous behind. + +// clientScreenBody is the shared header of every per-client screen: a user who +// tapped through four levels still sees which client they are on. +func (t *Tgbot) clientScreenBody(email, title string) string { + body := "" + html.EscapeString(email) + "\n" + if title != "" { + body += html.EscapeString(title) + "\n\n" + } + return body +} + +// screenClientIps draws the IP log with refresh and clear. +func (t *Tgbot) screenClientIps(chatID int64, email string) { + body := t.clientScreenBody(email, "") + ips, err := t.inboundService.GetInboundClientIps(email) + if err != nil || len(ips) == 0 { + body += t.I18nBot("tgbot.noIpRecord") + } else { + body += t.I18nBot("tgbot.messages.ips", "IPs=="+html.EscapeString(formatIPLog(ips))) + } + body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("ips_refresh "+email))), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.clearIPs", t.encodeQuery("clear_ips "+email))), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("client", body, rows...)) +} + +// formatIPLog renders the stored IP log, which is either a list of addresses or +// a list of {ip, timestamp} objects depending on when it was written. +func formatIPLog(raw string) string { + type ipWithTimestamp struct { + IP string `json:"ip"` + Timestamp int64 `json:"timestamp"` + } + var entries []ipWithTimestamp + if json.Unmarshal([]byte(raw), &entries) == nil && len(entries) > 0 { + lines := make([]string, 0, len(entries)) + for _, item := range entries { + if item.IP == "" { + continue + } + if item.Timestamp > 0 { + lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05"))) + continue + } + lines = append(lines, item.IP) + } + if len(lines) > 0 { + return strings.Join(lines, "\n") + } + } + var plain []string + if json.Unmarshal([]byte(raw), &plain) == nil && len(plain) > 0 { + return strings.Join(plain, "\n") + } + return raw +} + +// screenClientTG draws a client's Telegram binding. The user picker is the one +// reply keyboard kept: the API has no inline way to request a user. +func (t *Tgbot) screenClientTG(chatID int64, email string, withPicker bool) { + traffic, client, err := t.inboundService.GetClientByEmail(email) + if err != nil || client == nil { + t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) + return + } + tgID := t.I18nBot("tgbot.noValue") + if client.TgID != 0 { + tgID = strconv.FormatInt(client.TgID, 10) + } + body := t.clientScreenBody(email, "") + body += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgID) + body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + + rows := [][]telego.InlineKeyboardButton{} + if withPicker { + rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.selectTGUser", t.encodeQuery("tgid_pick "+email)))) + } + rows = append(rows, + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("tgid_refresh "+email))), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.removeTGUser", t.encodeQuery("tgid_remove "+email))), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))), + t.backRow(), + ) + t.renderScreen(chatID, t.newScreen("client", body, rows...)) + if withPicker { + t.sendTGPicker(chatID, traffic.Id) + } +} + +// sendTGPicker posts the native user picker — the only way the API asks for a +// user. It is a real message, so it carries the hide button and is removed later. +func (t *Tgbot) sendTGPicker(chatID int64, trafficID int) { + if liveBot() == nil { + return + } + t.sendNotice(chatID, t.I18nBot("tgbot.buttons.selectOneTGUser")) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + request := telego.KeyboardButtonRequestUsers{RequestID: int32(trafficID), UserIsBot: new(bool)} + keyboard := tu.Keyboard( + tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&request)), + tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard"))), + ).WithIsPersistent().WithResizeKeyboard() + _, _ = liveBot().SendMessage(ctx, &telego.SendMessageParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: t.I18nBot("tgbot.buttons.selectOneTGUser"), ReplyMarkup: keyboard, + }) +} + +// ownSubscriptionOnly reports whether the screen is the client's own single +// subscription, where admin-only controls must stay off (reached via "Links"). +func (t *Tgbot) ownSubscriptionOnly(chatID int64, email string) bool { + if checkAdmin(chatID) { + return false + } + traffics, err := t.inboundService.GetClientTrafficTgBot(chatID) + if err != nil { + return false + } + return len(traffics) == 1 && traffics[0].Email == email +} + +// screenClientLinks draws the subscription and the links of a client. +func (t *Tgbot) screenClientLinks(chatID int64, email string) { + subURL, subJSON, err := t.buildSubscriptionURLs(email) + if err != nil { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + body := t.clientScreenBody(email, "") + body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL)) + if subJSON != "" { + body += t.I18nBot("tgbot.messages.jsonURL", "URL=="+html.EscapeString(subJSON)) + } + body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.qrCode", t.encodeQuery("client_qr_links "+email))), + } + // A client looking at its own subscription has nothing behind the admin's + // card: it only repeats the usage already on its home screen. + if !t.ownSubscriptionOnly(chatID, email) { + rows = append(rows, tu.InlineKeyboardRow( + t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email)))) + } + rows = append(rows, t.backRow()) + t.renderScreen(chatID, t.newScreen("links", body, rows...)) +} + +// screenIndividualLinks renders the per-app links of the subscription as one +// (paged) message instead of one message per app. +func (t *Tgbot) screenIndividualLinks(chatID int64, email string) { + subURL, _, err := t.buildSubscriptionURLs(email) + if err != nil { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + links, err := t.clientSubLinks(email, subURL) + if err != nil || len(links) == 0 { + t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) + return + } + var body strings.Builder + body.WriteString(t.clientScreenBody(email, t.I18nBot("tgbot.buttons.individualLinks"))) + for _, link := range links { + body.WriteString("" + html.EscapeString(link) + "\n") + } + t.renderScreen(chatID, t.newScreen("links", body.String(), t.individualLinksRows()...)) +} + +// individualLinksRows is the individual-links screen's keyboard: exactly one +// control, the shared back row, since a second "Links" button was a duplicate. +func (t *Tgbot) individualLinksRows() [][]telego.InlineKeyboardButton { + return [][]telego.InlineKeyboardButton{t.backRow()} +} + +// screenClientQR renders the subscription QR as the screen picture and leaves +// the individual QRs to a document, so one screen covers the tap. +func (t *Tgbot) screenClientQR(chatID int64, email string) { + subURL, _, err := t.buildSubscriptionURLs(email) + if err != nil { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + body := t.clientScreenBody(email, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email)) + body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL)) + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))), + t.backRow(), + } + // One picture carrying every code the bot once sent as separate documents; + // tiles carry no text (they are scanned), so the caption legend numbers them. + codes := t.subscriptionQRCodes(email, subURL) + if len(codes) == 0 { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + sc := t.newScreen("qr", body+t.qrCodeLegend(codes), rows...) + sc.qrPicture = qrSheetPicture(codes) + sc.qrSource = subURL + if sc.qrPicture == nil { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + t.renderScreen(chatID, sc) +} + +// renderPickerOnClient redraws the client card with a picker's rows appended, so +// tapping a limit opens its value list on the same tracked screen. +func (t *Tgbot) renderPickerOnClient(chatID int64, email string, picker [][]telego.InlineKeyboardButton) { + traffic, err := t.inboundService.GetClientTrafficByEmail(email) + if err != nil || traffic == nil { + t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) + return + } + rows := append([][]telego.InlineKeyboardButton{}, picker...) + rows = append(rows, t.backRow()) + body := t.clientScreenBody(email, t.clientInfoMsg(traffic, true, true, true, true, true, false)) + t.renderScreen(chatID, t.newScreen("client", body, rows...)) +} + +// renderDraftPicker redraws the add-client draft with a value picker's rows +// appended: the wizard's own card stays visible while a value is chosen. +func (t *Tgbot) renderDraftPicker(chatID int64, draft *clientDraft, picker [][]telego.InlineKeyboardButton) { + rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...) + rows = append(rows, picker...) + t.renderScreen(chatID, t.newScreen("wizard", t.BuildClientDraftMessage(draft), rows...)) +} + +// adoptScreen marks the message the user is looking at as this chat's screen, +// so the next render edits it instead of stacking a second screen under it. +func (t *Tgbot) adoptScreen(chatID int64, msgID int) { + if msgID == 0 { + return + } + if _, ok := t.screens().get(chatID); ok { + return + } + t.screens().put(chatID, &screen{msgID: msgID}) +} + +// clientTrafficID resolves the traffic row id a client's TG-user picker needs. +// A missing row is 0: the caller's screen already reports the unknown client. +func (t *Tgbot) clientTrafficID(email string) int { + traffic, err := t.inboundService.GetClientTrafficByEmail(email) + if err != nil || traffic == nil { + return 0 + } + return traffic.Id +} + +// sendNoticeNoKeyboard reports a result and drops the client picker's reply +// keyboard in one message: this is the only place it is torn down. +func (t *Tgbot) sendNoticeNoKeyboard(chatID int64, text string) { + if liveBot() == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML", + ReplyMarkup: tu.ReplyKeyboardRemove(), + }) + if err != nil { + logger.Warning("Failed to send a notice:", err) + } +} + +// wizardPrompt asks for a text value on the draft screen itself: the wizard owns +// one message, so the question replaces the card; the reply keyboard is untouched. +func (t *Tgbot) wizardPrompt(chatID int64, draft *clientDraft, prompt string) { + rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...) + rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info"))) + t.renderScreen(chatID, t.newScreen("wizard", prompt, rows...)) +} + +// wizardInvalidInput redraws the draft with a rejection hint: the wizard keeps +// ONE message, so a retry does not stack a hint under a stale card. +func (t *Tgbot) wizardInvalidInput(chatID int64, draft *clientDraft) { + rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...) + rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info"))) + t.renderScreen(chatID, t.newScreen("wizard", + t.BuildClientDraftMessage(draft)+"\n\n"+t.I18nBot("tgbot.messages.incorrect_input"), rows...)) +} + +// screenInviteLink draws the invite link of a client as instructions plus the +// link itself, so an admin can forward it without leaving the screen. +func (t *Tgbot) screenInviteLink(chatID int64, email string) { + record, err := t.clientService.GetRecordByEmail(nil, email) + username := botUsername() + if err != nil || record.SubID == "" || username == "" { + logger.Warning("tgbot: invite link unavailable for", email, err) + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + payload, ok := encodeInvitePayload(record.SubID) + if !ok { + logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link") + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + link := "https://t.me/" + username + "?start=" + payload + body := t.clientScreenBody(email, t.I18nBot("tgbot.buttons.inviteLink")) + body += "" + html.EscapeString(link) + "\n\n" + body += t.I18nBot("tgbot.messages.inviteHint") + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("client", body, rows...)) +} + +// openOwnClientScreen resolves the client behind a Telegram user and opens the +// asked-for screen; several subscriptions become one button each. +func (t *Tgbot) openOwnClientScreen(chatID, tgUserID int64, action string) { + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + if len(traffics) == 0 { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + if len(traffics) == 1 { + t.openClientAction(chatID, action, traffics[0].Email) + return + } + var buttons []telego.InlineKeyboardButton + for _, tr := range traffics { + buttons = append(buttons, tu.InlineKeyboardButton(tr.Email). + WithCallbackData(t.encodeQuery(action+" "+tr.Email))) + } + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + rows := append(keyboard.InlineKeyboard, t.backRow()) + t.renderScreen(chatID, t.newScreen("links", t.I18nBot("tgbot.commands.pleaseChoose"), rows...)) +} + +// openClientAction opens one per-client screen by name. +func (t *Tgbot) openClientAction(chatID int64, action, email string) { + switch action { + case "client_sub_links": + t.screenClientLinks(chatID, email) + case "client_individual_links": + t.screenIndividualLinks(chatID, email) + case "client_qr_links": + t.screenClientQR(chatID, email) + } +} diff --git a/internal/web/service/tgbot/screen_inline.go b/internal/web/service/tgbot/screen_inline.go new file mode 100644 index 000000000..9db47f9e8 --- /dev/null +++ b/internal/web/service/tgbot/screen_inline.go @@ -0,0 +1,598 @@ +package tgbot + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "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" +) + +// The bot's lists are browsed in inline mode, which must be enabled by hand in +// @BotFather; when off, screens show a hint rather than a silent dead button. + +// The launcher carries its own intent as the query text (empty = all inbounds, +// "cl" = every client, "cl " = one inbound's), so nothing is kept server-side. +const ( + inlineScopeInbounds = "" + inlineScopeClients = "cl" +) + +// scopeQuery is the inline-query text the launcher opens with. +func scopeQuery(kind, data string) string { + if kind == inlineScopeClients && data != "" { + return inlineScopeClients + " " + data + } + if kind == inlineScopeClients { + return inlineScopeClients + } + return "" +} + +// inlineSupport caches getMe().supports_inline_queries. A permanent cache would +// freeze a pre-/setinline False forever, so it refreshes once stale. +type inlineSupport struct { + mu sync.Mutex + ok bool + known bool + at time.Time +} + +var inlineCapability = &inlineSupport{} + +const inlineCapabilityTTL = 10 * time.Minute + +// recordInlineCapability stores what getMe() reported. +func recordInlineCapability(supported bool) { + inlineCapability.mu.Lock() + inlineCapability.ok = supported + inlineCapability.known = true + inlineCapability.at = time.Now() + inlineCapability.mu.Unlock() +} + +// inlineSupported reports whether the launcher may be shown. A stale value is +// served and refreshed off-path; an unknown empty cache must not warn (post-restart bug). +func (t *Tgbot) inlineSupported() bool { + inlineCapability.mu.Lock() + ok, known, at := inlineCapability.ok, inlineCapability.known, inlineCapability.at + inlineCapability.mu.Unlock() + + if known && time.Since(at) <= inlineCapabilityTTL { + return ok + } + if !known { + t.refreshInlineCapability() + inlineCapability.mu.Lock() + defer inlineCapability.mu.Unlock() + return inlineCapability.ok + } + go t.refreshInlineCapability() + return ok +} + +func (t *Tgbot) refreshInlineCapability() { + if liveBot() == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + me, err := liveBot().GetMe(ctx) + if err != nil { + logger.Debug("Cannot read the bot capability for inline mode:", err) + return + } + recordInlineCapability(me.SupportsInlineQueries) +} + +// launchRow is the one way into a list: a launcher that opens inline mode in +// this chat, or the hint that says why there is none. +func (t *Tgbot) launchRow(query string, labelKey string) []telego.InlineKeyboardButton { + if !t.inlineSupported() { + return tu.InlineKeyboardRow(t.btn("tgbot.buttons.inlineDisabled", "inline_help")) + } + // Telegram prefills and returns this token as the query, so it is both intent + // and filter prefix; not localized because a translated marker could not parse. + btn := telego.InlineKeyboardButton{ + Text: t.I18nBot(labelKey), + SwitchInlineQueryCurrentChat: &query, + } + return tu.InlineKeyboardRow(btn) +} + +// Capabilities is the bot's own answer to "can you show lists?": inline mode is +// a BotFather switch, so the panel can only warn, never enable it. +type Capabilities struct { + InlineEnabled bool + // GroupPrivacy means the bot CANNOT read every group message, so a /start + // typed in a group never reaches it unless the message mentions the liveBot(). + GroupPrivacy bool + Username string + Running bool +} + +// Capabilities probes the live bot for what it can do. +func (t *Tgbot) Capabilities() (Capabilities, error) { + caps := Capabilities{Running: t.IsRunning(), Username: botUsername()} + if liveBot() == nil { + return caps, errors.New("bot is not initialised") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + me, err := liveBot().GetMe(ctx) + if err != nil { + return caps, err + } + caps.InlineEnabled = me.SupportsInlineQueries + // can_read_all_group_messages is the inverse of BotFather's Group Privacy. + caps.GroupPrivacy = !me.CanReadAllGroupMessages + caps.Username = me.Username + recordInlineCapability(me.SupportsInlineQueries) + return caps, nil +} + +// screenInlineHelp explains a bot that cannot open lists: inline mode is a +// BotFather switch, so the screen tells the admin exactly which one to flip. +func (t *Tgbot) screenInlineHelp(chatID int64) { + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.inline.recheck", "inline_recheck")), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("commands", + t.I18nBot("tgbot.inline.offTitle")+"\r\n\r\n"+t.I18nBot("tgbot.inline.offBody"), rows...)) +} + +// handleInlineQuery answers one query with the items of its scope. +func (t *Tgbot) handleInlineQuery(query *telego.InlineQuery) { + if liveBot() == nil { + return + } + level := t.levelOf(query.From.ID) + if level == levelStranger { + t.answerInline(query.ID, nil, "") + return + } + // Telegram prefills the typed text with the launcher's query, so the intent + // arrives before anything the user adds; a bare query means inbounds. + action, data, search := parseInlineQuery(query.Query) + results := t.inlineResults(action, data, search, level == levelAdmin) + t.answerInline(query.ID, results, query.Offset) +} + +// maxInlineResults is the Bot API's hard cap on one answer. Exceeding it gets +// the whole answer rejected, which on a big panel means an empty list forever. +const maxInlineResults = 50 + +// pageInlineResults returns one page and the cursor for the next. The full list +// is built first because the cap is a property of the transport, not of a list. +func pageInlineResults(results []telego.InlineQueryResult, offset string) (page []telego.InlineQueryResult, next string) { + start, err := strconv.Atoi(offset) + if err != nil || start < 0 { + start = 0 + } + if start >= len(results) { + return nil, "" + } + end := start + maxInlineResults + if end < len(results) { + return results[start:end], strconv.Itoa(end) + } + return results[start:], "" +} + +// markerResultID keeps a result id inside the 64-byte cap Telegram enforces: one +// long email would otherwise sink the entire answer. +func markerResultID(marker string) string { + if len(marker) <= 63 { + return marker + } + sum := sha256.Sum256([]byte(marker)) + return "h:" + hex.EncodeToString(sum[:16]) +} + +// answerInline answers with page, or with the "nothing here" card: an +// unanswered query leaves Telegram spinning. +func (t *Tgbot) answerInline(queryID string, results []telego.InlineQueryResult, offset string) { + page, next := pageInlineResults(results, offset) + if len(results) == 0 { + hint := t.I18nBot("tgbot.inline.noneTitle") + page = []telego.InlineQueryResult{&telego.InlineQueryResultArticle{ + Type: "article", + ID: "none", + Title: hint, + Description: t.I18nBot("tgbot.inline.noneDesc"), + InputMessageContent: &telego.InputTextMessageContent{ + MessageText: "none:" + hint, + }, + }} + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + err := liveBot().AnswerInlineQuery(ctx, &telego.AnswerInlineQueryParams{ + InlineQueryID: queryID, + Results: page, + NextOffset: next, + CacheTime: 0, + IsPersonal: true, + }) + if err != nil { + // A stale query id is normal (the user kept typing); never a failure. + logger.Debug("answerInlineQuery failed:", err) + } +} + +// inlineResults builds one inline query's items. Inbound lists are admin-only: +// that browser leaked every inbound's remark, protocol, traffic and client count. +func (t *Tgbot) inlineResults(action, data, search string, isAdmin bool) []telego.InlineQueryResult { + if action == inlineScopeClients { + return t.clientResults(search, isAdmin, data) + } + if !isAdmin { + return nil + } + if action == "add" { + return t.inboundResults(search, "add") + } + return t.inboundResults(search, "inb") +} + +// parseInlineQuery splits "cl 3 user@x" into its action, inbound and the text to +// filter by. Nothing found means the inbounds list. +func parseInlineQuery(text string) (action, data, search string) { + trimmed := strings.TrimSpace(text) + lower := strings.ToLower(trimmed) + for _, marker := range []string{inlineScopeClients, "add"} { + if !strings.HasPrefix(lower, marker) { + continue + } + rest := strings.TrimSpace(trimmed[len(marker):]) + if marker == inlineScopeClients { + // "cl 3 name" — the first token is the inbound when it is a number. + fields := strings.SplitN(rest, " ", 2) + if len(fields) > 0 && fields[0] != "" { + if _, err := strconv.Atoi(fields[0]); err == nil { + if len(fields) == 2 { + return marker, fields[0], fields[1] + } + return marker, fields[0], "" + } + } + return marker, "", rest + } + return marker, "", rest + } + return inlineScopeInbounds, "", trimmed +} + +// inboundResults lists inbounds as markers. action is what a chosen item means: +// "inb" opens the inbound, "add" starts the add-client wizard on it. +func (t *Tgbot) inboundResults(search, action string) []telego.InlineQueryResult { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Cannot list inbounds for inline mode:", err) + return nil + } + needle := strings.ToLower(strings.TrimSpace(search)) + results := make([]telego.InlineQueryResult, 0, len(inbounds)) + for _, ib := range inbounds { + remark := ib.Remark + if remark == "" { + remark = "#" + strconv.Itoa(ib.Id) + } + status := "❌" + if ib.Enable { + status = "✅" + } + description := fmt.Sprintf("#%d · %s · %s · 🚦 %s\n👥 %d", ib.Id, ib.Protocol, status, + common.FormatTraffic(ib.Up+ib.Down), len(ib.ClientStats)) + if needle != "" && !strings.Contains(strings.ToLower(remark+" "+string(ib.Protocol)+" "+strconv.Itoa(ib.Port)), needle) { + continue + } + marker := action + ":" + strconv.Itoa(ib.Id) + results = append(results, &telego.InlineQueryResultArticle{ + Type: "article", + ID: markerResultID(marker), + Title: remark, + Description: description, + InputMessageContent: &telego.InputTextMessageContent{ + MessageText: marker, + }, + }) + } + return results +} + +// clientResults lists clients of one inbound, or of all of them when inboundID +// is empty: an admin browses the panel, a client only ever sees its own. +func (t *Tgbot) clientResults(search string, isAdmin bool, inboundID string) []telego.InlineQueryResult { + needle := strings.ToLower(strings.TrimSpace(search)) + results := make([]telego.InlineQueryResult, 0, 32) + + add := func(email, remark string, used, limit, expiry int64, enabled, online bool) { + if needle != "" && !strings.Contains(strings.ToLower(email+" "+remark), needle) { + return + } + marker := "cl:" + email + results = append(results, &telego.InlineQueryResultArticle{ + Type: "article", + ID: markerResultID(marker), + Title: email, // the title is the one thing every client shows + // Telegram renders about three lines here, so the three facts a + // client screen answers first get one line each. + Description: t.clientCardLines(remark, used, limit, expiry, enabled, online), + InputMessageContent: &telego.InputTextMessageContent{ + MessageText: marker, + }, + }) + } + + if !isAdmin { + return results + } + + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Cannot list clients for inline mode:", err) + return results + } + seen := map[string]bool{} + for _, ib := range inbounds { + if inboundID != "" && strconv.Itoa(ib.Id) != inboundID { + continue + } + clients, err := t.inboundService.GetClients(ib) + if err != nil { + continue + } + for _, client := range clients { + if seen[client.Email] { + continue + } + seen[client.Email] = true + used := int64(0) + if traffic, err := t.inboundService.GetClientTrafficByEmail(client.Email); err == nil && traffic != nil { + used = traffic.Up + traffic.Down + } + add(client.Email, ib.Remark, used, client.TotalGB, client.ExpiryTime, client.Enable, false) + } + } + return results +} + +// clientCardLines is an inline result's description: three lines, because that +// is what Telegram renders under the title. +func (t *Tgbot) clientCardLines(remark string, used, limit, expiry int64, enabled, online bool) string { + head := "❌" + if enabled { + head = "✅" + } + if online { + head += " 🟢" + } + if remark != "" { + head += " · " + remark + } + + traffic := common.FormatTraffic(used) + if limit > 0 { + // The percentage is what people actually compare between clients. + traffic += " / " + common.FormatTraffic(limit) + + fmt.Sprintf(" · %d%%", used*100/limit) + } else { + traffic += " / " + t.I18nBot("tgbot.unlimited") + } + + expiryText := t.I18nBot("tgbot.unlimited") + switch { + case expiry < 0: + // A negative term is days counted from first use, not a date. + expiryText = t.durationText(-expiry*86400000, true) + case expiry > 0: + expiryText = t.durationText(expiry-time.Now().UnixMilli(), false) + } + return head + "\n🚦 " + traffic + "\n📅 " + expiryText +} + +// durationText is a compact, localized span: "12 Minutes", "5 Hours", "30 Days". +// fromFirstUse names the not-yet-started case, whose clock has not begun. +func (t *Tgbot) durationText(ms int64, fromFirstUse bool) string { + if ms <= 0 { + return t.I18nBot("tgbot.inline.expired") + } + if fromFirstUse { + return fmt.Sprintf("%d %s (%s)", ms/86400000, t.I18nBot("tgbot.days"), + t.I18nBot("tgbot.inline.fromFirstUse")) + } + switch { + case ms >= 30*86400000: + return fmt.Sprintf("%d %s", ms/(30*86400000), t.I18nBot("tgbot.months")) + case ms >= 86400000: + return fmt.Sprintf("%d %s", ms/86400000, t.I18nBot("tgbot.days")) + case ms >= 3600000: + return fmt.Sprintf("%d %s", ms/3600000, t.I18nBot("tgbot.hours")) + default: + return fmt.Sprintf("%d %s", ms/60000, t.I18nBot("tgbot.minutes")) + } +} + +// handleListMarker catches an item picked in inline mode. The marker arrives as +// the user's own message, so it is deleted first, then the real card is drawn. +func (t *Tgbot) handleListMarker(message *telego.Message) bool { + if message == nil || message.Text == "" { + return false + } + action, value, ok := splitListMarker(message.Text) + if !ok { + return false + } + + level := t.levelOf(message.From.ID) + if level == levelStranger { + return true + } + if !t.markerAllowed(action, value, message.From.ID, level == levelAdmin) { + return true + } + // Only a marker that actually opens something is litter worth removing. + t.deleteIncoming(message) + + switch action { + case "inb": + id, err := strconv.Atoi(value) + if err != nil { + return true + } + t.screenInboundClients(message.Chat.ID, id) + case "add": + id, err := strconv.Atoi(value) + if err != nil { + return true + } + t.startAddClientForInbound(message.Chat.ID, message.From.ID, id) + case "cl": + if t.clientOwnedByTgUser(message.From.ID, value) || level == levelAdmin { + t.searchClientScreen(message.Chat.ID, value) + } + } + return true +} + +// markerAllowed checks the marker against the sender: an admin marker needs the +// admin level, a client marker must name a client that account owns. +func (t *Tgbot) markerAllowed(action, value string, tgUserID int64, isAdmin bool) bool { + switch action { + case "inb", "add": + return isAdmin + case "cl": + if isAdmin { + return true + } + return t.clientOwnedByTgUser(tgUserID, value) + } + return false +} + +// messageIsListMarker matches only a picked inline item; a "text contains a +// colon" predicate once swallowed broadcasts and wizard messages (URL, time, comment). +func messageIsListMarker(_ context.Context, update telego.Update) bool { + if update.Message == nil { + return false + } + _, _, ok := splitListMarker(update.Message.Text) + return ok +} + +// splitListMarker parses "inb:3", "add:3" or "cl:user@x". +func splitListMarker(text string) (action, value string, ok bool) { + for _, candidate := range []string{"inb:", "add:", "cl:"} { + if rest, found := strings.CutPrefix(text, candidate); found && rest != "" { + return strings.TrimSuffix(candidate, ":"), rest, true + } + } + return "", "", false +} + +// screenInboundClients draws one inbound's clients, listed by the inline query. +func (t *Tgbot) screenInboundClients(chatID int64, inboundID int) { + inbound, err := t.inboundService.GetInbound(inboundID) + if err != nil { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.getInboundsFailed")) + return + } + // The clients are browsed in inline mode, never as a callback-button grid: + // a big inbound would otherwise fill the screen with one button per client. + rows := [][]telego.InlineKeyboardButton{ + t.launchRow(scopeQuery(inlineScopeClients, strconv.Itoa(inboundID)), "tgbot.buttons.searchClients"), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("clients", t.inboundSummaryText(inbound), rows...)) +} + +// screenAllClients is the panel-wide client browser. There is no inbound step: +// the list is every client, and a tap opens the client's own screen. +func (t *Tgbot) screenAllClients(chatID int64) { + body := t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(t.clientCount())) + body += t.I18nBot("tgbot.messages.inlineHint") + rows := [][]telego.InlineKeyboardButton{ + t.launchRow(scopeQuery(inlineScopeClients, ""), "tgbot.buttons.searchClients"), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("clients", body, rows...)) +} + +// clientCount is how many clients the panel holds, for the browser's header. +func (t *Tgbot) clientCount() int { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Cannot count clients for the browser:", err) + return 0 + } + seen := map[string]bool{} + for _, ib := range inbounds { + clients, err := t.inboundService.GetClients(ib) + if err != nil { + continue + } + for _, client := range clients { + seen[client.Email] = true + } + } + return len(seen) +} + +// inboundSummaryText is one inbound's header line: remark, protocol, port. +func (t *Tgbot) inboundSummaryText(inbound *model.Inbound) string { + body := t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark) + body += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)) + body += 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 { + body += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")) + } else { + body += t.I18nBot("tgbot.messages.expire", + "Time=="+time.Unix(inbound.ExpiryTime/1000, 0).Format("2006-01-02 15:04:05")) + } + body += t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(len(inbound.ClientStats))) + return body +} + +// startAddClientForInbound opens the add-client wizard on a chosen inbound. +func (t *Tgbot) startAddClientForInbound(chatID, userID int64, inboundID int) { + if _, err := t.inboundService.GetInbound(inboundID); err != nil { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.getInboundsFailed")) + return + } + draft := addClientDrafts.forActor(chatUser{chatID: chatID, userID: userID}) + draft.Lock() + defer draft.Unlock() + t.resetDraft(draft) + draft.receiverInboundID = inboundID + draft.receiverInboundIDs = []int{inboundID} + t.addClient(chatID, draft, t.BuildClientDraftMessage(draft)) +} + +// resetDraft clears the wizard back to its defaults for a fresh client. +func (t *Tgbot) resetDraft(draft *clientDraft) { + draft.email = t.randomLowerAndNum(8) + draft.limitIP = 0 + draft.totalGB = 0 + draft.expiryTime = 0 + draft.enable = true + draft.tgID = "" + draft.subID = t.randomLowerAndNum(16) + draft.comment = "" + draft.reset = 0 +} diff --git a/internal/web/service/tgbot/screen_inline_test.go b/internal/web/service/tgbot/screen_inline_test.go new file mode 100644 index 000000000..afa273032 --- /dev/null +++ b/internal/web/service/tgbot/screen_inline_test.go @@ -0,0 +1,620 @@ +package tgbot + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/mymmrac/telego" +) + +// inlineCapturingServer records answerInlineQuery results so a test can assert +// on the list Telegram would show, and on the marker an item carries. +func inlineCapturingServer(t *testing.T, supportsInline bool) (*httptest.Server, func() []map[string]any) { + t.Helper() + var mu sync.Mutex + var queries []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + var payload map[string]any + _ = json.Unmarshal(body, &payload) + w.Header().Set("Content-Type", "application/json") + switch method { + case "answerInlineQuery": + mu.Lock() + queries = append(queries, payload) + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true}) + case "getMe": + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "id": 42, "is_bot": true, "first_name": "bot", "username": "bot", + "supports_inline_queries": supportsInline, + }}) + case "getChat": + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "id": 42, "type": "private", "photo": map[string]any{"big_file_id": "big"}, + }}) + case "sendMessage", "sendPhoto", "sendDocument": + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": 5, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}, + }}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true}) + } + })) + return srv, func() []map[string]any { + mu.Lock() + defer mu.Unlock() + return append([]map[string]any(nil), queries...) + } +} + +// maybeAnswerInline waits briefly for the asynchronous query answer. +func maybeAnswerInline(get func() []map[string]any) []map[string]any { + for range 20 { + if got := get(); len(got) > 0 { + return got + } + time.Sleep(10 * time.Millisecond) + } + return get() +} + +func TestInlineQueryListsInboundsWithMarkers(t *testing.T) { + srv, queries := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + + tb := initReportDB(t) + setTestAdmins(t, 1) + seedReportClients(t, "nl-fast", []string{"a@x"}) + + tb.handleInlineQuery(&telego.InlineQuery{ + ID: "iq1", + From: telego.User{ID: 1}, + Query: "", + }) + + got := maybeAnswerInline(queries) + if len(got) != 1 { + t.Fatalf("answerInlineQuery calls = %d, want 1", len(got)) + } + results, _ := got[0]["results"].([]any) + if len(results) == 0 { + t.Fatal("inline results are empty, want the seeded inbound") + } + item, _ := results[0].(map[string]any) + id, _ := item["id"].(string) + if !strings.HasPrefix(id, "inb:") { + t.Errorf("result id = %q, want an inb: marker", id) + } + content, _ := item["input_message_content"].(map[string]any) + if text, _ := content["message_text"].(string); text != id { + t.Errorf("marker text = %q, want the marker itself (%q), never the real content", text, id) + } +} + +// /start must draw the menu; it also reaches the route the colon predicate guards, pinning that a +// command is never mistaken for an inline marker. +func TestStartCommandDrawsTheMenu(t *testing.T) { + srv, calls := recordingServer(t, false) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + setTestAdmins(t, 1) + + tb := initReportDB(t) + + tb.answerCommand(&telego.Message{ + MessageID: 1, + Chat: telego.Chat{ID: 500, Type: "private"}, + From: &telego.User{ID: 1}, + Text: "/start", + }, 500, true) + + got := calls() + if len(got) == 0 { + t.Fatal("an admin's /start drew nothing") + } + // The menu is drawn as a photo when art is cached and as text otherwise; + // either way the call has to be there. + drew := false + for _, c := range got { + if c.Method == "sendPhoto" || c.Method == "sendMessage" { + drew = true + } + } + if !drew { + t.Errorf("calls = %v, want the menu message", methods(got)) + } +} + +// A stranger's /start reports its own id, which is what an admin needs to bind +// it; it must never render the panel menu. +func TestStartCommandForAStrangerRevealsOnlyItsID(t *testing.T) { + srv, calls := recordingServer(t, false) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + setTestAdmins(t, 1) + + tb := initReportDB(t) + + tb.answerCommand(&telego.Message{ + MessageID: 1, + Chat: telego.Chat{ID: 777, Type: "private"}, + From: &telego.User{ID: 777777}, + Text: "/start", + }, 777, false) + + got := calls() + if len(got) == 0 { + t.Fatal("a stranger's /start got no answer at all") + } + // The locale key is what the test environment resolves to; the panel's own + // bundle renders it as the stranger's id, which an admin needs to bind it. + if !strings.Contains(got[0].text(), "askToAddUserId") { + t.Errorf("answer = %q, want the bind-your-id notice", got[0].text()) + } +} + +// A client list stays scoped to the inbound its screen named, through the inline query, which carries +// the user's id, not the group chat's. +func TestInlineQueryListsClientsOfTheChosenInbound(t *testing.T) { + srv, queries := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + inlineCapability.recordForTest(true) + + tb := initReportDB(t) + setTestAdmins(t, 1) + seedReportClients(t, "nl-fast", []string{"a@x"}) + seedReportClients(t, "de-slow", []string{"b@x"}) + + inbounds, err := tb.inboundService.GetAllInbounds() + if err != nil || len(inbounds) != 2 { + t.Fatalf("seeded inbounds = %d (err %v), want 2", len(inbounds), err) + } + chosen := inbounds[0] + + // The screen is drawn in a group: chat id 500, the admin's own id 1. + tb.screenInboundClients(500, chosen.Id) + + // The launcher prefills the query with the browser's intent; that text is + // what Telegram sends back, so the test sends exactly it. + sc, ok := tb.screens().get(500) + if !ok { + t.Fatal("the client screen was not rendered") + } + var queryText string + for _, row := range sc.markup.InlineKeyboard { + for _, btn := range row { + if btn.SwitchInlineQueryCurrentChat != nil { + queryText = *btn.SwitchInlineQueryCurrentChat + } + } + } + if queryText == "" { + t.Fatal("the client screen carries no inline launcher") + } + tb.handleInlineQuery(&telego.InlineQuery{ID: "iq4", From: telego.User{ID: 1}, Query: queryText}) + + got := maybeAnswerInline(queries) + if len(got) != 1 { + t.Fatalf("answerInlineQuery calls = %d, want 1", len(got)) + } + results, _ := got[0]["results"].([]any) + if len(results) != 1 { + t.Fatalf("results = %d, want exactly the chosen inbound's single client", len(results)) + } + item, _ := results[0].(map[string]any) + if id, _ := item["id"].(string); !strings.HasPrefix(id, "cl:") { + t.Errorf("result id = %q, want a cl: marker, never an inbound", id) + } + if title, _ := item["title"].(string); title != "a@x" { + t.Errorf("result title = %q, want the client of the chosen inbound", title) + } +} + +// The client screen is browsed in inline mode only: a callback-button grid of +// every client is what the screen layer exists to remove. +func TestClientScreenCarriesNoClientButtons(t *testing.T) { + srv, _ := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + inlineCapability.recordForTest(true) + + tb := initReportDB(t) + setTestAdmins(t, 1) + seedReportClients(t, "nl-fast", []string{"a@x", "b@x"}) + + inbounds, err := tb.inboundService.GetAllInbounds() + if err != nil || len(inbounds) == 0 { + t.Fatalf("seeded inbounds = %d (err %v), want at least 1", len(inbounds), err) + } + tb.screenInboundClients(500, inbounds[0].Id) + + sc, ok := tb.screens().get(500) + if !ok || sc.markup == nil { + t.Fatal("the client screen was not rendered") + } + var buttons []string + for _, row := range sc.markup.InlineKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.CallbackData) + } + } + for _, data := range buttons { + if strings.Contains(data, "client_get_usage") || strings.Contains(data, "@x") { + t.Errorf("the client screen carries the client button %q; clients are inline-only", data) + } + } + if len(buttons) == 0 { + t.Error("the client screen lost its buttons entirely") + } +} + +// A query with no list screen behind it must fall back to inbounds: listing +// every client would hand the panel's client names to anyone who can type. +func TestInlineQueryWithoutScopeListsInboundsNotClients(t *testing.T) { + srv, queries := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + inlineCapability.recordForTest(true) + + tb := initReportDB(t) + setTestAdmins(t, 1) + seedReportClients(t, "nl-fast", []string{"a@x"}) + + tb.handleInlineQuery(&telego.InlineQuery{ID: "iq5", From: telego.User{ID: 1}, Query: ""}) + + got := maybeAnswerInline(queries) + results, _ := got[0]["results"].([]any) + for _, raw := range results { + item, _ := raw.(map[string]any) + if id, _ := item["id"].(string); strings.HasPrefix(id, "cl:") { + t.Fatalf("an unscoped query leaked the client %q", id) + } + } +} + +// An unknown capability cache must not answer "inline is off": after a restart it warned a bot whose +// inline mode was on until the background refresh landed. +func TestInlineSupportProbesWhenUnknown(t *testing.T) { + srv, _ := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + + // Fresh process: nothing was recorded yet. + inlineCapability.mu.Lock() + inlineCapability.ok, inlineCapability.known = false, false + inlineCapability.mu.Unlock() + + tb := &Tgbot{} + if !tb.inlineSupported() { + t.Error("an unknown cache answered inline-off; the probe must run first") + } + if !inlineCapability.known { + t.Error("the probe did not record the capability") + } +} + +// An inline client card is what a user reads before tapping: the email as the +// title, and three lines of facts under it. +func TestInlineClientCardCarriesTrafficAndExpiry(t *testing.T) { + srv, queries := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + inlineCapability.recordForTest(true) + + tb := initReportDB(t) + setTestAdmins(t, 1) + seedReportClients(t, "nl-fast", []string{"a@x"}) + + tb.handleInlineQuery(&telego.InlineQuery{ID: "iq7", From: telego.User{ID: 1}, Query: inlineScopeClients}) + + got := maybeAnswerInline(queries) + results, _ := got[0]["results"].([]any) + if len(results) == 0 { + t.Fatal("the client browser returned nothing") + } + item, _ := results[0].(map[string]any) + if title, _ := item["title"].(string); title != "a@x" { + t.Errorf("title = %q, want the client's email", title) + } + desc, _ := item["description"].(string) + if strings.Count(desc, "\n") != 2 { + t.Errorf("description = %q, want three lines (two newlines)", desc) + } + for _, want := range []string{"🚦", "📅"} { + if !strings.Contains(desc, want) { + t.Errorf("description = %q, want the %s line", desc, want) + } + } +} + +// All clients is its own browser: it lists clients straight away and never asks +// for an inbound first. +func TestAllClientsScreenListsClientsWithoutAnInboundStep(t *testing.T) { + srv, queries := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + inlineCapability.recordForTest(true) + + tb := initReportDB(t) + setTestAdmins(t, 1) + seedReportClients(t, "nl-fast", []string{"a@x", "b@x"}) + + tb.screenAllClients(500) + + sc, ok := tb.screens().get(500) + if !ok || sc.markup == nil { + t.Fatal("All clients drew no screen") + } + var queryText string + for _, row := range sc.markup.InlineKeyboard { + for _, btn := range row { + if btn.SwitchInlineQueryCurrentChat != nil { + queryText = *btn.SwitchInlineQueryCurrentChat + } + } + } + if queryText != inlineScopeClients { + t.Fatalf("launcher query = %q, want the all-clients marker %q", queryText, inlineScopeClients) + } + + tb.handleInlineQuery(&telego.InlineQuery{ID: "iq6", From: telego.User{ID: 1}, Query: queryText}) + + got := maybeAnswerInline(queries) + results, _ := got[0]["results"].([]any) + if len(results) != 2 { + t.Fatalf("results = %d, want both seeded clients", len(results)) + } + for _, raw := range results { + item, _ := raw.(map[string]any) + if id, _ := item["id"].(string); !strings.HasPrefix(id, "cl:") { + t.Errorf("result id = %q, want a cl: marker, never an inbound", id) + } + } +} + +// The query text is the whole intent, so its parsing is pinned: a server-side +// scope is what kept sending the client step back to the inbound list. +func TestParseInlineQuery(t *testing.T) { + cases := []struct { + text, action, data, search string + }{ + {"", inlineScopeInbounds, "", ""}, + {"nl", inlineScopeInbounds, "", "nl"}, + {inlineScopeClients, inlineScopeClients, "", ""}, + {inlineScopeClients + " 3", inlineScopeClients, "3", ""}, + {inlineScopeClients + " 3 vas", inlineScopeClients, "3", "vas"}, + {inlineScopeClients + " vas", inlineScopeClients, "", "vas"}, + {"add", "add", "", ""}, + {"add nl", "add", "", "nl"}, + } + for _, c := range cases { + action, data, search := parseInlineQuery(c.text) + if action != c.action || data != c.data || search != c.search { + t.Errorf("parseInlineQuery(%q) = (%q, %q, %q), want (%q, %q, %q)", + c.text, action, data, search, c.action, c.data, c.search) + } + } +} + +func TestInlineQueryAnswersEmptyResult(t *testing.T) { + srv, queries := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + + tb := initReportDB(t) + setTestAdmins(t, 1) + + tb.handleInlineQuery(&telego.InlineQuery{ID: "iq2", From: telego.User{ID: 1}, Query: "nothing-matches-this"}) + + got := maybeAnswerInline(queries) + if len(got) != 1 { + t.Fatalf("answerInlineQuery calls = %d, want 1: an empty list must still answer", len(got)) + } + if results, _ := got[0]["results"].([]any); len(results) == 0 { + t.Error("results are empty, want the none-found card") + } +} + +func TestInlineQueryRefusesAStranger(t *testing.T) { + srv, queries := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + + tb := initReportDB(t) + setTestAdmins(t, 1) + seedReportClients(t, "secret", []string{"a@x"}) + + // 777777 owns no client and is not an admin. + tb.handleInlineQuery(&telego.InlineQuery{ID: "iq3", From: telego.User{ID: 777777}}) + + got := maybeAnswerInline(queries) + if len(got) != 1 { + t.Fatalf("answerInlineQuery calls = %d, want 1", len(got)) + } + results, _ := got[0]["results"].([]any) + for _, raw := range results { + item, _ := raw.(map[string]any) + if id, _ := item["id"].(string); strings.HasPrefix(id, "inb:") || strings.HasPrefix(id, "cl:") { + t.Fatalf("a stranger received the results item %q", id) + } + } +} + +func TestInlineLauncherOnlyWhenInlineIsOn(t *testing.T) { + srv, _ := inlineCapturingServer(t, true) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + inlineCapability.recordForTest(true) + + tb := &Tgbot{} + row := tb.launchRow("inb", "tgbot.buttons.searchInbounds") + if len(row) != 1 || row[0].SwitchInlineQueryCurrentChat == nil { + t.Fatalf("launcher row = %+v, want a switch-inline button", row) + } + + inlineCapability.recordForTest(false) + row = tb.launchRow("inb", "tgbot.buttons.searchInbounds") + if len(row) != 1 || row[0].SwitchInlineQueryCurrentChat != nil { + t.Fatalf("row = %+v, want the inline-off hint instead of a dead button", row) + } + if row[0].CallbackData != "inline_help" { + t.Errorf("hint callback = %q, want inline_help", row[0].CallbackData) + } +} + +// setTestAdmins registers admins for the duration of a test. +func setTestAdmins(t *testing.T, ids ...int64) { + t.Helper() + orig := adminSnapshot() + tgBotMutex.Lock() + adminIds = ids + tgBotMutex.Unlock() + t.Cleanup(func() { + tgBotMutex.Lock() + adminIds = orig + tgBotMutex.Unlock() + }) +} + +// recordForTest pins the capability cache without a round trip. +func (s *inlineSupport) recordForTest(supported bool) { + s.mu.Lock() + s.ok = supported + s.known = true + s.at = time.Now() + s.mu.Unlock() +} + +func TestListMarkerParsesAndRejectsForeignPrefixes(t *testing.T) { + for _, tt := range []struct { + text string + action string + value string + ok bool + }{ + {"inb:3", "inb", "3", true}, + {"add:12", "add", "12", true}, + {"cl:user@x", "cl", "user@x", true}, + {"hello world", "", "", false}, + {"inb:", "", "", false}, + {"http://x", "", "", false}, + } { + action, value, ok := splitListMarker(tt.text) + if ok != tt.ok || action != tt.action || value != tt.value { + t.Errorf("splitListMarker(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.text, action, value, ok, tt.action, tt.value, tt.ok) + } + } +} + +// A picked item is the only thing the marker route may match. The predicate used +// to be "text contains a colon", which ate broadcast text with a URL or a time. +func TestOnlyAPickedItemMatchesTheMarkerRoute(t *testing.T) { + cases := []struct { + text string + want bool + }{ + {"inb:3", true}, + {"add:12", true}, + {"cl:user@x", true}, + {"https://example.com/a", false}, + {"backup at 03:00", false}, + {"Paid: 2026-11", false}, + {"note: see panel", false}, + {"inb:", false}, + {"", false}, + } + for _, c := range cases { + update := telego.Update{Message: &telego.Message{Text: c.text, From: &telego.User{ID: 1}}} + if got := messageIsListMarker(context.Background(), update); got != c.want { + t.Errorf("messageIsListMarker(%q) = %v, want %v", c.text, got, c.want) + } + } +} + +// Telegram rejects an answer with more than 50 results, so a big panel has to +// page: an un-paged answer is rejected whole and the list looks empty forever. +func TestBigListIsPagedInsteadOfRejected(t *testing.T) { + all := make([]telego.InlineQueryResult, 0, 120) + for i := range 120 { + all = append(all, &telego.InlineQueryResultArticle{ + Type: "article", ID: strconv.Itoa(i), Title: strconv.Itoa(i), + InputMessageContent: &telego.InputTextMessageContent{MessageText: "cl:x" + strconv.Itoa(i)}, + }) + } + + first, next := pageInlineResults(all, "") + if len(first) != maxInlineResults { + t.Fatalf("first page = %d results, want %d", len(first), maxInlineResults) + } + if next != "50" { + t.Fatalf("first page cursor = %q, want %q", next, "50") + } + second, next2 := pageInlineResults(all, next) + if len(second) != 50 { + t.Fatalf("second page = %d results, want 50", len(second)) + } + if next2 != "100" { + t.Fatalf("second page cursor = %q, want %q", next2, "100") + } + last, next3 := pageInlineResults(all, next2) + if len(last) != 20 { + t.Fatalf("last page = %d results, want 20", len(last)) + } + if next3 != "" { + t.Fatalf("last page cursor = %q, want empty", next3) + } + if _, beyond := pageInlineResults(all, "500"); beyond != "" { + t.Fatalf("past-the-end cursor = %q, want empty", beyond) + } + if got, _ := pageInlineResults(all, "not-a-number"); len(got) != maxInlineResults { + t.Fatalf("garbage cursor returned %d results, want a first page", len(got)) + } +} + +// A short list must not grow a cursor, or Telegram keeps paging an exhausted list. +func TestShortListHasNoCursor(t *testing.T) { + list := []telego.InlineQueryResult{&telego.InlineQueryResultArticle{Type: "article", ID: "a"}} + page, next := pageInlineResults(list, "") + if len(page) != 1 || next != "" { + t.Fatalf("page = %d results, cursor = %q; want 1 and empty", len(page), next) + } +} + +// Telegram caps a result id at 64 bytes; one long email would sink the answer. +func TestLongMarkerStaysInsideTheResultIDLimit(t *testing.T) { + long := "cl:" + strings.Repeat("a", 200) + "@example.com" + id := markerResultID(long) + if len(id) > 64 { + t.Fatalf("result id is %d bytes, want at most 64", len(id)) + } + if id == markerResultID("cl:"+strings.Repeat("a", 201)+"@example.com") { + t.Fatal("two different markers hashed to the same id") + } + if got := markerResultID("cl:user@x"); got != "cl:user@x" { + t.Fatalf("short marker = %q, want it unchanged", got) + } +} diff --git a/internal/web/service/tgbot/screen_legacy.go b/internal/web/service/tgbot/screen_legacy.go new file mode 100644 index 000000000..9ef71071f --- /dev/null +++ b/internal/web/service/tgbot/screen_legacy.go @@ -0,0 +1,146 @@ +package tgbot + +import ( + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// Bridges for the screens still served by the legacy views. Each one is a +// screen now, so the router's callback cases only decide which view to draw. + +// clearScreen drops the tracked screen and the user's own message: the pilot +// tap that closes the reply keyboard must leave nothing behind. +func (t *Tgbot) clearScreen(message telego.Message) { + t.deleteIncoming(&message) + if sc, ok := t.screens().get(message.Chat.ID); ok && sc.msgID != 0 { + t.deleteOwnMessage(message.Chat.ID, sc.msgID) + } + t.screens().clear(message.Chat.ID) +} + +// hideMessage deletes the very message whose button was tapped. The id comes +// from the callback, so nothing has to be remembered to make it work. +func (t *Tgbot) hideMessage(query *telego.CallbackQuery) { + if query.Message == nil { + return + } + chat := query.Message.GetChat() + t.deleteOwnMessage(chat.ID, query.Message.GetMessageID()) + // A hidden notice must not stay the chat's screen either, and the next event + // of its kind has to start a fresh card rather than edit a deleted one. + if sc, ok := t.screens().get(chat.ID); ok && sc.msgID == query.Message.GetMessageID() { + t.screens().clear(chat.ID) + } + t.dropNoticeFor(chat.ID, query.Message.GetMessageID()) + t.answerSilent(query.ID) +} + +// turnPage moves the stored screen one body-page back or forward. +func (t *Tgbot) turnPage(query *telego.CallbackQuery, delta int) { + chatID := query.Message.GetChat().ID + sc, ok := t.screens().get(chatID) + if !ok { + t.answerSilent(query.ID) + return + } + t.showPage(chatID, sc.page+delta) + t.answerSilent(query.ID) +} + +// resetAllConfirm is the confirmation screen for the panel-wide traffic reset. +func (t *Tgbot) resetAllConfirm() *screen { + // The forward action and the way out must be different rows: sharing one + // row made "Cancel" read as part of the confirmation. + return t.newScreen("main", t.I18nBot("tgbot.messages.AreYouSure"), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.confirmResetTraffic", "reset_all_traffics_c")), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.back", cbHome)), + ) +} + +// screenInbounds summarises the inbounds; the browsable, searchable list is the +// inline query, so the screen carries only the summary and the way back. +func (t *Tgbot) screenInbounds(chatID, userID int64) { + _ = userID + body := t.getInboundUsages() + body += t.I18nBot("tgbot.messages.inlineHint") + rows := [][]telego.InlineKeyboardButton{ + t.launchRow(scopeQuery(inlineScopeInbounds, ""), "tgbot.buttons.searchInbounds"), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("inbounds", body, rows...)) +} + +// browseInboundsScreen is the pick-an-inbound step: the list itself is the +// inline browser, so the screen carries the hint and the search launcher. +func (t *Tgbot) browseInboundsScreen(chatID, userID int64) { + _ = userID + rows := [][]telego.InlineKeyboardButton{ + t.launchRow(scopeQuery(inlineScopeInbounds, ""), "tgbot.buttons.searchInbounds"), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("inbounds", t.I18nBot("tgbot.answers.chooseInbound"), rows...)) +} + +// screenAddClientStart opens the add-client wizard on a fresh draft: filling the +// draft is the first step, and inbounds are picked on "➕ Attach inbound". +func (t *Tgbot) screenAddClientStart(chatID, userID int64) { + actor := chatUser{chatID: chatID, userID: userID} + addClientDrafts.reset(actor) + draft := addClientDrafts.forActor(actor) + draft.Lock() + defer draft.Unlock() + t.resetDraft(draft) + t.addClient(chatID, draft, t.BuildClientDraftMessage(draft)) +} + +// restartXrayFromScreen restarts Xray and reports the outcome as a toast, so +// the menu screen itself stays put. +func (t *Tgbot) restartXrayFromScreen(query *telego.CallbackQuery) { + if !t.xrayService.IsXrayRunning() { + t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.xrayNotRunning")) + return + } + if err := t.xrayService.RestartXray(true); err != nil { + logger.Warning("Xray restart failed:", err) + t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error())) + return + } + t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.restartSuccess")) +} + +// searchClientScreen renders the client card as a screen; every per-email +// callback that used to post a message lands here. +func (t *Tgbot) searchClientScreen(chatID int64, email string) { + traffic, err := t.inboundService.GetClientTrafficByEmail(email) + if err != nil || traffic == nil { + t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) + return + } + body := t.clientInfoMsg(traffic, true, true, true, true, true, true) + t.renderScreen(chatID, t.newScreen("client", body, t.clientRows(email)...)) +} + +// clientRows is the client card's keyboard, in one place so every entry point +// draws an identical card. +func (t *Tgbot) clientRows(email string) [][]telego.InlineKeyboardButton { + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("client_refresh "+email))), + tu.InlineKeyboardRow( + t.btn("tgbot.buttons.resetTraffic", t.encodeQuery("reset_traffic "+email)), + t.btn("tgbot.buttons.limitTraffic", t.encodeQuery("limit_traffic "+email)), + ), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.resetExpire", t.encodeQuery("reset_exp "+email))), + tu.InlineKeyboardRow( + t.btn("tgbot.buttons.ipLog", t.encodeQuery("ip_log "+email)), + t.btn("tgbot.buttons.ipLimit", t.encodeQuery("ip_limit "+email)), + ), + tu.InlineKeyboardRow( + t.btn("tgbot.buttons.setTGUser", t.encodeQuery("tg_user "+email)), + t.btn("tgbot.buttons.inviteLink", t.encodeQuery("client_invite_link "+email)), + ), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.toggle", t.encodeQuery("toggle_enable "+email))), + t.backRow(), + } +} diff --git a/internal/web/service/tgbot/screen_view.go b/internal/web/service/tgbot/screen_view.go new file mode 100644 index 000000000..e147743ed --- /dev/null +++ b/internal/web/service/tgbot/screen_view.go @@ -0,0 +1,112 @@ +package tgbot + +import ( + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// Every screen's keyboard is built here, one builder per screen: keyboards built +// inside the router's callback cases could be edited without their screen noticing. + +const ( + cbHome = "home" + cbActions = "act" + cbHide = "hide" + cbPageNext = "pg:next" + cbPagePrev = "pg:prev" + cbPageNone = "pg:none" + cbServer = "srv" + cbInbounds = "inb" + cbClients = "cli" + cbOnlines = "onl" + cbDeplete = "dep" + cbReport = "rep" + cbBackup = "bkp" + cbBanLogs = "ban" + cbAddClient = "addc" + cbCatServer = "cat:server" + cbCatClients = "cat:clients" + cbCatTraffic = "cat:traffic" + cbBroadcast = "bc" + cbCatMaintenance = "cat:maintenance" + cbResetAll = "rst_all" + cbRestartXry = "xray_restart" +) + +// homeRows is the admin's menu: four categories and a refresh, one row per +// subject, so every subject is one tap deeper and nothing is more than two away. +func (t *Tgbot) homeRows() [][]telego.InlineKeyboardButton { + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow( + t.btn("tgbot.buttons.catServer", cbCatServer), + t.btn("tgbot.buttons.catClients", cbCatClients), + ), + tu.InlineKeyboardRow( + t.btn("tgbot.buttons.catTraffic", cbCatTraffic), + t.btn("tgbot.buttons.catMaintenance", cbCatMaintenance), + ), + tu.InlineKeyboardRow( + t.btn("tgbot.buttons.refresh", cbHome), + ), + } +} + +// categoryRows is one menu subject's own rows. +func (t *Tgbot) categoryRows(category string) [][]telego.InlineKeyboardButton { + home := t.backRow() + switch category { + case "server": + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.serverUsage", cbServer)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.getInbounds", cbInbounds)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.onlines", cbOnlines)), + home, + } + case "clients": + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.allClients", cbClients)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.addClient", cbAddClient)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.broadcast", cbBroadcast)), + home, + } + case "traffic": + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.SortedTrafficUsageReport", cbReport)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.depleteSoon", cbDeplete)), + home, + } + case "maintenance": + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.dbBackup", cbBackup)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.getBanLogs", cbBanLogs)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.restartXray", cbRestartXry)), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.ResetAllTraffics", cbResetAll)), + home, + } + } + return [][]telego.InlineKeyboardButton{home} +} + +// screenCategory draws one menu subject. The hint line names the subject, so a +// user who arrived through a notification can see where they are. +func (t *Tgbot) screenCategory(chatID int64, category string) { + body := t.I18nBot("tgbot.messages.category_" + category) + rows := [][]telego.InlineKeyboardButton{} + rows = append(rows, t.categoryRows(category)...) + t.renderScreen(chatID, t.newScreen("main", body, rows...)) +} + +// btn is where a localized label becomes a callback button; btn2 is btn with a +// caller-supplied label, since a client's email names its subscription best. +func (t *Tgbot) btn2(label, data string) telego.InlineKeyboardButton { + return tu.InlineKeyboardButton(label).WithCallbackData(data) +} + +func (t *Tgbot) btn(labelKey, data string) telego.InlineKeyboardButton { + return tu.InlineKeyboardButton(t.I18nBot(labelKey)).WithCallbackData(data) +} + +// backRow is the single "up" control a non-root screen carries. +func (t *Tgbot) backRow() []telego.InlineKeyboardButton { + return tu.InlineKeyboardRow(t.btn("tgbot.buttons.back", cbHome)) +} diff --git a/internal/web/service/tgbot/screen_views.go b/internal/web/service/tgbot/screen_views.go new file mode 100644 index 000000000..3303ffa11 --- /dev/null +++ b/internal/web/service/tgbot/screen_views.go @@ -0,0 +1,338 @@ +package tgbot + +import ( + "context" + "fmt" + "html" + "os" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// Views for the read-only screens. Each one is a pure "read state, return a +// screen" function; the router only decides which view to show. + +// screenHome is the admin's landing screen: the numbers you open the bot for, +// then the menu, with the bot's avatar until the panel can host its own artwork. +func (t *Tgbot) screenHome(chatID, userID int64) { + if level := t.levelOf(userID); level != levelAdmin { + t.screenClientHome(chatID, userID) + return + } + body := t.homeSummary() + t.renderScreen(chatID, t.newScreen("main", body, t.homeRows()...)) +} + +// screenClientHome is a client's own entry point: its usage, then links and QR. +// /start once gave clients the ADMIN menu (panel ops, every client's email). +func (t *Tgbot) screenClientHome(chatID, userID int64) { + traffics, err := t.inboundService.GetClientTrafficTgBot(userID) + if err != nil || len(traffics) == 0 { + t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(userID, 10))) + return + } + var body strings.Builder + for _, traffic := range traffics { + body.WriteString(t.clientInfoMsg(traffic, true, true, true, true, true, true)) + body.WriteString("\r\n") + } + // One subscription needs no choice (links and QR are right here); several are + // one row each. Either way this is the client's top level, so no back button. + var rows [][]telego.InlineKeyboardButton + if len(traffics) == 1 { + rows = append(rows, tu.InlineKeyboardRow( + t.btn("tgbot.buttons.links", "client_sub_links"), + t.btn("tgbot.buttons.qrCode", "client_qr_links"), + )) + } else { + for _, traffic := range traffics { + rows = append(rows, tu.InlineKeyboardRow( + t.btn2(traffic.Email, t.encodeQuery("client_sub_links "+traffic.Email)), + )) + } + } + t.renderScreen(chatID, t.rootScreen("main", body.String(), rows...)) +} + +// homeSummary is the panel overview line used by the menu and the report, so +// the two can never disagree about the host or the version. +func (t *Tgbot) homeSummary() string { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Unable to load inbounds for the menu:", err) + } + clients := 0 + online := 0 + traffic := int64(0) + for _, ib := range inbounds { + clients += len(ib.ClientStats) + traffic += ib.Up + ib.Down + } + if process := xrayProcessOrNil(); process != nil { + online = len(process.GetOnlineClients()) + } + body := "" + body += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) + body += t.I18nBot("tgbot.messages.version", "Version=="+panelVersion()) + body += t.I18nBot("tgbot.messages.inboundsCount", "Count=="+strconv.Itoa(len(inbounds))) + body += t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(clients)) + body += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(online)) + body += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(traffic), + "Upload=="+common.FormatTraffic(traffic), "Download=="+common.FormatTraffic(0)) + return body +} + +// screenServer renders the server-usage screen. +func (t *Tgbot) screenServer(chatID int64) { + body := strings.TrimRight(t.prepareServerUsageInfo(), "\r\n") + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbServer)), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("status", body, rows...)) +} + +// screenOnlines lists who is connected right now; client names are plain text +// (the inline browser takes over the per-client pick next). +func (t *Tgbot) screenOnlines(chatID int64) { + process := xrayProcessOrNil() + if process == nil { + t.renderScreen(chatID, t.newScreen("onlines", t.I18nBot("tgbot.wentWrong"), + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)), t.backRow())) + return + } + onlines := process.GetOnlineClients() + body := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(len(onlines))) + for _, online := range onlines { + label := online + if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" { + label = online + " · " + inbound.Remark + } + body += "• " + html.EscapeString(label) + "\n" + } + body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("onlines", body, rows...)) +} + +// screenDeplete renders the "running out" screen; the scheduled report and the +// button share this text, so a client is never listed twice with a different story. +func (t *Tgbot) screenDeplete(chatID int64) { + body := t.depleteReport() + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbDeplete)), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("deplete", body, rows...)) +} + +// depleteReport builds the exhausted/disabled summary as plain text. +func (t *Tgbot) depleteReport() string { + trDiff, exDiff := t.depletionThresholds() + now := time.Now().Unix() * 1000 + + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Unable to load inbounds for the depletion report:", err) + } + exhaustedInbounds, disabledInbounds := 0, 0 + exhaustedClients, disabledClients := 0, 0 + seen := map[string]bool{} + var clientLines strings.Builder + + for _, inbound := range inbounds { + if !inbound.Enable { + disabledInbounds++ + continue + } + if (inbound.ExpiryTime > 0 && inbound.ExpiryTime-now < exDiff) || + (inbound.Total > 0 && inbound.Total-(inbound.Up+inbound.Down) < trDiff) { + exhaustedInbounds++ + } + for _, client := range inbound.ClientStats { + if seen[client.Email] { + continue + } + seen[client.Email] = true + if !client.Enable { + disabledClients++ + continue + } + if (client.ExpiryTime > 0 && client.ExpiryTime-now < exDiff) || + (client.Total > 0 && client.Total-(client.Up+client.Down) < trDiff) { + exhaustedClients++ + clientLines.WriteString(t.clientInfoMsg(&client, true, false, false, true, true, false)) + clientLines.WriteString("\n") + } + } + } + + body := "" + body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds")) + body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledInbounds)) + body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedInbounds)) + body += "\n" + body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")) + body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledClients)) + body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedClients)) + if clientLines.Len() > 0 { + body += "\n" + html.EscapeString(clientLines.String()) + } + body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + return body +} + +// depletionThresholds reads the two "running out" windows from the settings. +func (t *Tgbot) depletionThresholds() (traffic, expire int64) { + if v, err := t.settingService.GetTrafficDiff(); err == nil && v > 0 { + traffic = int64(v) * 1073741824 + } + if v, err := t.settingService.GetExpireDiff(); err == nil && v > 0 { + expire = int64(v) * 86400000 + } + return traffic, expire +} + +// screenReport renders the sorted traffic-usage report. The screen pages it, +// so a large panel gets one message with page controls, not a message flood. +func (t *Tgbot) screenReport(chatID int64) { + emails, err := t.inboundService.GetAllEmails() + if err != nil { + t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow())) + return + } + validEmails, missingEmails, err := t.inboundService.FilterAndSortClientEmails(emails) + if err != nil { + t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow())) + return + } + + var report strings.Builder + for _, email := range validEmails { + traffic, err := t.inboundService.GetClientTrafficByEmail(email) + if err != nil || traffic == nil { + fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult")) + continue + } + report.WriteString(t.clientInfoMsg(traffic, false, false, false, false, true, false)) + report.WriteString("\n") + } + for _, email := range missingEmails { + fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult")) + } + rows := [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbReport)), + t.backRow(), + } + t.renderScreen(chatID, t.newScreen("reports", html.EscapeString(report.String()), rows...)) +} + +// screenCommands explains the classic commands; kept because muscle memory is +// real, but the menu no longer advertises them. +func (t *Tgbot) screenCommands(chatID int64, isAdmin bool) { + key := "tgbot.commands.helpClientCommands" + if isAdmin { + key = "tgbot.commands.helpAdminCommands" + } + t.renderScreen(chatID, t.newScreen("commands", t.I18nBot(key), t.backRow())) +} + +// sendBackupScreen delivers the backup and summary as ONE message (file plus +// caption); a summary too long for a caption is sent after the file. +func (t *Tgbot) sendBackupScreen(chatID int64) { + dbData, err := t.serverService.GetDb() + if err != nil { + logger.Error("Error in getting db backup:", err) + } + summary := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) + summary += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + + if dbData == nil { + t.sendNotice(chatID, summary) + return + } + // The summary and the depletion list share the file's caption: one message + // carries the backup and what it is for. + body := summary + "\n\n" + t.depleteReport() + caption := trimCaption(body, botCaptionLimit) + if t.sendDocumentWithCaption(chatID, dbData, t.serverService.BackupFilename(""), caption) { + // The generated config travels with the DB on both backup paths: an + // operator restoring from the file would otherwise lose it silently. + if config, err := os.ReadFile(xray.GetConfigPath()); err == nil { + t.sendDocumentWithCaption(chatID, config, "config.json", "") + } + if rest := strings.TrimSpace(strings.TrimPrefix(body, caption)); rest != "" { + // Rare: the summary outgrew a caption. The file already landed. + t.sendNotice(chatID, rest) + } + return + } + // The upload failed: the summary is still worth sending. + t.sendNotice(chatID, summary) +} + +// screenBanLogs sends the ban log file as a notice, never as a screen. +func (t *Tgbot) screenBanLogs(chatID int64) { + 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.sendNotice(chatID, output) + t.sendBanLogsFile(chatID) +} + +// sendNotice posts a plain bot message that is explicitly not a screen: it +// carries the hide button, and it never becomes the chat's tracked screen. +func (t *Tgbot) sendNotice(chatID int64, text string) { + t.sendNoticeReturningID(chatID, text) +} + +// sendNoticeReturningID posts one dismissable message and hands back its id, so +// the caller can replace it later instead of leaving copies behind. +func (t *Tgbot) sendNoticeReturningID(chatID int64, text string) int { + if liveBot() == nil || text == "" { + return 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + sent, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML", ReplyMarkup: t.hideButton(), + }) + if err != nil { + logger.Warning("Failed to send a notice:", err) + return 0 + } + return sent.MessageID +} + +// sendBanLogsFile delivers the banned-IP log as a notice with a hide button. +func (t *Tgbot) sendBanLogsFile(chatID int64) { + file, err := os.Open(xray.GetIPLimitBannedPrevLogPath()) + if err != nil { + t.sendNotice(chatID, t.I18nBot("tgbot.noResult")) + return + } + defer file.Close() + if liveBot() == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + _, err = liveBot().SendDocument(ctx, &telego.SendDocumentParams{ + ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), + Document: tu.File(file), + ReplyMarkup: t.hideButton(), + }) + if err != nil { + logger.Warning("Failed to upload the ban log:", err) + } +} diff --git a/internal/web/service/tgbot/screens_art.go b/internal/web/service/tgbot/screens_art.go new file mode 100644 index 000000000..44887a7c9 --- /dev/null +++ b/internal/web/service/tgbot/screens_art.go @@ -0,0 +1,179 @@ +package tgbot + +import ( + "bytes" + "context" + "image" + "image/color" + "image/draw" + "image/png" + "sync" + "sync/atomic" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// Screen art: the bot's avatar serves every screen's picture, and a panel without +// one gets a generated black tile, so a screen never fails for want of a picture. + +var ( + screenArtMu sync.RWMutex + screenArtIDs = map[string]string{} + // artOverrides is artwork the panel set for one screen kind. It outranks + // the bot's avatar, so adopting an avatar never silently replaces it. + artOverrides = map[string]string{} + // artFromAvatar records whether the cached ids came from the bot's avatar: a + // tile cached from an upload must never win over an avatar set afterwards. + artFromAvatar bool + artProbedAt time.Time + // artProbeRunning keeps a burst of renders from starting a burst of probes. + artProbeRunning atomic.Bool + + blackTileOnce sync.Once + blackTileBytes []byte +) + +// Probes are cheap but not free: a known avatar is trusted for a while, a missing +// one retried sooner, so an uploaded picture shows without a restart. +const ( + artAvatarTTL = 10 * time.Minute + artRetryTTL = time.Minute +) + +// resolveScreenArt caches the bot avatar's file_id for every screen kind. +// Telegram serves one photo per bot, so all kinds share it. +func (t *Tgbot) resolveScreenArt() { + // One read: the panel can swap the bot under us (login/logout), and reading + // the global per call is a nil-pointer crash in this background goroutine. + b := liveBot() + if b == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + me, err := b.GetMe(ctx) + if err != nil { + logger.Warning("Failed to read the bot identity for screen art:", err) + return + } + + // Use getUserProfilePhotos, not getChat: getChat's ChatPhoto file_id is + // refused in sendPhoto ("can't use file of type ChatPhoto as Photo"). + photos, err := b.GetUserProfilePhotos(ctx, &telego.GetUserProfilePhotosParams{ + UserID: me.ID, Limit: 1, + }) + if err != nil { + logger.Debug("Failed to read the bot's profile photos for screen art:", err) + return + } + + screenArtMu.Lock() + defer screenArtMu.Unlock() + artProbedAt = time.Now() + if len(photos.Photos) == 0 || len(photos.Photos[0]) == 0 { + // Remember the miss so the next render does not probe again, but keep + // any tile already cached: an avatar-less bot still shows a picture. + artFromAvatar = false + logger.Debug("Bot has no avatar; screens will use the black fallback") + return + } + // The last entry is the largest size Telegram keeps. + sizes := photos.Photos[0] + fileID := sizes[len(sizes)-1].FileID + screenArtIDs = map[string]string{} + for _, kind := range screenKinds { + screenArtIDs[kind] = fileID + } + artFromAvatar = true +} + +// ensureScreenArt refreshes stale cached art off the render path, at most one +// probe in flight, so a just-uploaded picture shows without a restart. +func (t *Tgbot) ensureScreenArt() { + screenArtMu.RLock() + fromAvatar, at := artFromAvatar, artProbedAt + screenArtMu.RUnlock() + + ttl := artRetryTTL + if fromAvatar { + ttl = artAvatarTTL + } + if time.Since(at) <= ttl { + return + } + if !artProbeRunning.CompareAndSwap(false, true) { + return + } + go func() { + defer artProbeRunning.Store(false) + t.resolveScreenArt() + }() +} + +// artFileID returns the cached file_id for a screen kind. A fallback tile cached +// for one kind must not answer for another, so the per-kind entry is authoritative. +func artFileID(kind string) (string, bool) { + screenArtMu.RLock() + defer screenArtMu.RUnlock() + if id, ok := artOverrides[kind]; ok && id != "" { + return id, true + } + id, ok := screenArtIDs[kind] + return id, ok && id != "" +} + +// artUpload is the last-resort picture: the generated black tile. +func artUpload(kind string) (telego.InputFile, bool) { + data := blackTile() + return tu.FileFromBytes(data, "screen.png"), data != nil +} + +// blackTile builds the 512x512 black tile once. +func blackTile() []byte { + blackTileOnce.Do(func() { + img := image.NewRGBA(image.Rect(0, 0, 512, 512)) + draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{R: 13, G: 15, B: 20, A: 255}}, image.Point{}, draw.Src) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + logger.Warning("Failed to encode the fallback screen art:", err) + return + } + blackTileBytes = buf.Bytes() + }) + return blackTileBytes +} + +// cacheArtFileID remembers the file_id Telegram returned for an uploaded tile, +// so that screen edits in place. It does NOT mark the art as the avatar. +func cacheArtFileID(kind, fileID string) { + if kind == "" || fileID == "" { + return + } + screenArtMu.Lock() + if !artFromAvatar { + screenArtIDs[kind] = fileID + } + screenArtMu.Unlock() +} + +// resetScreenArt drops the cached file_ids; tests use it between mock servers. +func resetScreenArt() { + screenArtMu.Lock() + screenArtIDs = map[string]string{} + artOverrides = map[string]string{} + artFromAvatar = false + artProbedAt = time.Time{} + screenArtMu.Unlock() +} + +// screenKinds is the catalogue of screen pictures: every kind resolves to the +// same avatar, but the names keep each screen's intent visible in the code. +var screenKinds = []string{ + "main", "status", "inbounds", "clients", "client", "links", "qr", + "wizard", "reports", "backup", "onlines", "deplete", "commands", "broadcast", +} diff --git a/internal/web/service/tgbot/screens_test.go b/internal/web/service/tgbot/screens_test.go new file mode 100644 index 000000000..97fd648fb --- /dev/null +++ b/internal/web/service/tgbot/screens_test.go @@ -0,0 +1,1117 @@ +package tgbot + +import ( + "bytes" + "encoding/json" + "fmt" + "image/png" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" + "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// recordingServer answers the Bot API calls the screen layer makes and keeps +// them in order, so a test can assert on the sequence, not just the outcome. +type apiCall struct { + Method string + Payload map[string]any +} + +func (c apiCall) text() string { + if v, ok := c.Payload["text"].(string); ok { + return v + } + if v, ok := c.Payload["caption"].(string); ok { + return v + } + return "" +} + +func recordingServer(t *testing.T, failEdit bool) (*httptest.Server, func() []apiCall) { + t.Helper() + var mu sync.Mutex + var calls []apiCall + nextID := 100 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + var payload map[string]any + _ = json.Unmarshal(body, &payload) + if strings.HasPrefix(string(body), "{") { + // JSON payload: keep it as decoded. + } else { + payload = map[string]any{} + } + mu.Lock() + calls = append(calls, apiCall{Method: method, Payload: payload}) + nextID++ + id := nextID + mu.Unlock() + + fail := func(desc string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 400, "description": desc}) + } + switch method { + case "editMessageMedia", "editMessageText", "editMessageCaption": + if failEdit { + fail("Bad Request: message to edit not found") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": id, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}, + "photo": []any{map[string]any{"file_id": "ph", "file_unique_id": "u", "width": 1, "height": 1}}, + }}) + case "sendPhoto", "sendMessage", "sendDocument": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": id, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}, + }}) + case "copyMessage": + // Telegram answers with a MessageID object, not a boolean. + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": id, + }}) + case "copyMessages": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": []any{ + map[string]any{"message_id": id}, + }}) + case "getMe": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "id": 42, "is_bot": true, "first_name": "bot", "username": "bot", + }}) + case "getChat": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "id": 42, "type": "private", "photo": map[string]any{"small_file_id": "s", "big_file_id": "big"}, + }}) + case "getUserProfilePhotos": + // sendPhoto rejects a getChat ChatPhoto id ("can't use file of type ChatPhoto as Photo"), so the + // avatar must arrive as a Photo file_id. + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "total_count": 1, + "photos": []any{[]any{ + map[string]any{"file_id": "small-photo", "file_unique_id": "u1", "width": 160, "height": 160}, + map[string]any{"file_id": "big-photo", "file_unique_id": "u2", "width": 640, "height": 640}, + }}, + }}) + default: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true}) + } + })) + return srv, func() []apiCall { + mu.Lock() + defer mu.Unlock() + return append([]apiCall(nil), calls...) + } +} + +// newScreenTgbot points the package bot at a recording server and clears the +// process-wide screen state the previous test may have left behind. +func newScreenTgbot(t *testing.T, failEdit bool) (*Tgbot, func() []apiCall) { + t.Helper() + srv, calls := recordingServer(t, failEdit) + swapTestBot(t, srv.URL) + t.Cleanup(srv.Close) + resetScreenArt() + return &Tgbot{}, calls +} + +func methods(calls []apiCall) []string { + out := make([]string, 0, len(calls)) + for _, c := range calls { + out = append(out, c.Method) + } + return out +} + +func indexOf(calls []apiCall, method string) int { + for i, c := range calls { + if c.Method == method { + return i + } + } + return -1 +} + +func TestScreenRendersOnceAndEditsAfterwards(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + + tb.renderScreen(1, tb.newScreen("main", "first", tb.homeRows()...)) + tb.renderScreen(1, tb.newScreen("main", "second", tb.homeRows()...)) + + got := calls() + if n := countMethod(got, "sendPhoto") + countMethod(got, "sendMessage"); n != 1 { + t.Fatalf("screen messages = %d, want 1: one screen per chat (calls: %v)", n, methods(got)) + } + if n := countMethod(got, "editMessageMedia"); n != 1 { + t.Fatalf("editMessageMedia = %d, want 1: the second render must edit (calls: %v)", n, methods(got)) + } + if n := countMethod(got, "deleteMessage"); n != 0 { + t.Errorf("deleteMessage = %d, want 0: an edit needs no replacement", n) + } +} + +func TestScreenReplacesInSendThenDeleteOrder(t *testing.T) { + tb, calls := newScreenTgbot(t, true) + + tb.renderScreen(1, tb.newScreen("main", "live", tb.homeRows()...)) + before := len(calls()) + tb.renderScreen(1, tb.newScreen("main", "refreshed", tb.homeRows()...)) + + got := calls()[before:] + send := indexOf(got, "sendPhoto") + if send < 0 { + send = indexOf(got, "sendMessage") + } + del := indexOf(got, "deleteMessage") + if send < 0 || del < 0 { + t.Fatalf("calls = %v, want a re-send and a delete", methods(got)) + } + if del < send { + t.Errorf("calls = %v, want the new screen sent BEFORE the old one is deleted", methods(got)) + } +} + +func TestScreenTreatsNotModifiedAsSuccess(t *testing.T) { + var mu sync.Mutex + var calls []apiCall + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + mu.Lock() + calls = append(calls, apiCall{Method: method}) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch method { + case "editMessageMedia", "editMessageText": + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, "error_code": 400, + "description": "Bad Request: message is not modified", + }) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ + "message_id": 7, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}, + }}) + } + })) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + + tb := &Tgbot{} + tb.renderScreen(1, tb.newScreen("main", "one", tb.homeRows()...)) + mu.Lock() + before := len(calls) + mu.Unlock() + tb.renderScreen(1, tb.newScreen("main", "one", tb.homeRows()...)) + + mu.Lock() + after := append([]apiCall(nil), calls[before:]...) + mu.Unlock() + for _, c := range after { + if c.Method == "deleteMessage" || c.Method == "sendPhoto" || c.Method == "sendMessage" { + t.Fatalf("calls after the no-op edit = %v, want no replacement", methods(after)) + } + } +} + +func TestScreenCaptionFitsTelegramLimitAndPages(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + + short := tb.newScreen("main", "hello", tb.homeRows()...) + if short.pageMax != 1 { + t.Fatalf("pageMax = %d, want 1 for a short body", short.pageMax) + } + tb.renderScreen(1, short) + if len(short.text) > botCaptionLimit { + t.Errorf("caption is %d chars, want at most %d", len(short.text), botCaptionLimit) + } + + long := tb.newScreen("main", strings.Repeat("line of text\n", 300), tb.homeRows()...) + if long.pageMax < 2 { + t.Fatalf("pageMax = %d, want the long body paged", long.pageMax) + } + tb.renderScreen(1, long) + if len([]rune(long.text)) > botCaptionLimit { + t.Errorf("paged caption is %d runes, want at most %d", len([]rune(long.text)), botCaptionLimit) + } + // The pager must be reachable: a body nobody can page through is a dead end. + found := false + for _, row := range long.markup.InlineKeyboard { + for _, btn := range row { + if btn.CallbackData == cbPageNext { + found = true + } + } + } + if !found { + t.Error("paged screen has no next-page button") + } +} + +func TestHideButtonDeletesItsOwnMessage(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + + tb.hideMessage(&telego.CallbackQuery{ + ID: "q1", + Message: &telego.Message{MessageID: 55, Chat: telego.Chat{ID: 1}}, + }) + + got := calls() + for _, c := range got { + if c.Method != "deleteMessage" { + continue + } + if id, ok := c.Payload["message_id"].(float64); ok && int(id) == 55 { + return + } + } + t.Fatalf("calls = %v, want deleteMessage of the tapped message 55", methods(got)) +} + +func TestHomeScreenCarriesTheMenu(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + sc := tb.newScreen("main", "summary", tb.homeRows()...) + data := map[string]bool{} + for _, row := range sc.rows { + for _, btn := range row { + data[btn.CallbackData] = true + } + } + // The menu opens with categories only: each subject is one tap deeper, and + // the entry screen must not grow back into the wall of buttons. + for _, want := range []string{cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance, cbHome} { + if !data[want] { + t.Errorf("menu is missing the %q button", want) + } + } + for _, old := range []string{cbServer, cbInbounds, cbClients, cbOnlines, cbDeplete, cbReport, cbBackup, cbBanLogs, cbAddClient} { + if data[old] { + t.Errorf("menu still carries %q directly; it belongs in a category", old) + } + } + + // Every category the menu offers must be drawn, and carry its own entries. + for _, category := range []string{"server", "clients", "traffic", "maintenance"} { + rows := tb.categoryRows(category) + if len(rows) < 2 { + t.Errorf("category %q has %d rows, want its entries plus a way back", category, len(rows)) + continue + } + flat := map[string]bool{} + for _, row := range rows { + for _, btn := range row { + flat[btn.CallbackData] = true + } + } + if !flat[cbHome] { + t.Errorf("category %q has no way back to the menu", category) + } + } +} + +// hasHomeRow reports whether a screen carries the way back to the menu. +func hasHomeRow(sc *screen) bool { + for _, row := range sc.rows { + for _, btn := range row { + if btn.CallbackData == cbHome { + return true + } + } + } + return false +} + +// A screen whose only buttons are its own actions traps the user once one runs, as the reset +// confirmation did; every screen must offer a way out. +func TestEveryScreenOffersAWayOut(t *testing.T) { + tb := &Tgbot{} + + ownActionOnly := tb.newScreen("main", "body", + tu.InlineKeyboardRow(tb.btn("tgbot.buttons.confirmResetTraffic", "reset_all_traffics_c"))) + if !hasHomeRow(ownActionOnly) { + t.Error("a screen with only its own action got no way back") + } + + // A screen that already ends with Back must not grow a second copy. + withBack := tb.newScreen("main", "body", tb.backRow()) + backs := 0 + for _, row := range withBack.rows { + for _, btn := range row { + if btn.CallbackData == cbHome { + backs++ + } + } + } + if backs != 1 { + t.Errorf("back rows = %d, want exactly 1", backs) + } + + // And the screens the menu actually draws. + for name, sc := range map[string]*screen{ + "reset confirmation": tb.resetAllConfirm(), + "home": tb.newScreen("main", "x", tb.homeRows()...), + "category": tb.newScreen("main", "x", tb.categoryRows("maintenance")...), + } { + if !hasHomeRow(sc) { + t.Errorf("%s has no way back to the menu", name) + } + } +} + +// The reset confirmation must not stay on screen after the reset runs: its +// Cancel was the way back and its only other button was the one just pressed. +func TestResetConfirmIsReplacedByTheMenu(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + setTestAdmins(t, 1) + initReportDB(t) + seedReportClients(t, "nl-fast", []string{"a@x"}) + + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", + From: telego.User{ID: 1}, + Data: "reset_all_traffics_c", + Message: &telego.Message{Chat: telego.Chat{ID: 1}}, + }, true) + + if countMethod(calls(), "sendPhoto")+countMethod(calls(), "sendMessage") == 0 { + t.Fatal("the reset drew no message at all") + } + // The chat's tracked screen has to be the menu now, not the confirmation. + sc, ok := tb.screens().get(1) + if !ok { + t.Fatal("no screen is tracked after the reset") + } + if !hasHomeRow(sc) { + t.Error("the screen left behind after the reset has no way back") + } + if sc.kind != "main" { + t.Errorf("tracked screen kind = %q, want the menu", sc.kind) + } +} + +// A client's /start must never render the admin menu, which advertised operations a client cannot run +// and listed every client's email. +func TestClientStartNeverShowsTheAdminMenu(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + setTestAdmins(t, 1) + initReportDB(t) + seedReportClients(t, "nl-fast", []string{"mine@x"}) + + // A client: owns a record, is not an admin. + screenArtMu.Lock() + artFromAvatar, artProbedAt = false, time.Now() + screenArtMu.Unlock() + + tb.screenHome(500, 777777) + + sc, ok := tb.screens().get(500) + if !ok || sc.markup == nil { + // No record at all: the client is told its id instead. That is fine. + return + } + for _, row := range sc.rows { + for _, btn := range row { + switch btn.CallbackData { + case cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance, cbBroadcast, cbAddClient: + t.Errorf("a client's menu carries the admin-only %q button", btn.CallbackData) + } + } + } +} + +// The broadcast the /broadcast command already drove must be reachable as a +// button, and only for an admin. +func TestBroadcastButtonIsAdminOnly(t *testing.T) { + tb := &Tgbot{} + found := false + for _, category := range []string{"server", "clients", "traffic", "maintenance"} { + for _, row := range tb.categoryRows(category) { + for _, btn := range row { + if btn.CallbackData == cbBroadcast { + found = true + if category != "clients" { + t.Errorf("broadcast sits in %q, want the clients category", category) + } + } + } + } + } + if !found { + t.Error("no category carries the broadcast button") + } +} + +// A missing avatar is retried sooner than a present one: an operator checks right after uploading, and +// waiting for the next restart reads as the feature not working. +func TestScreenArtReprobeWindow(t *testing.T) { + srv, calls := recordingServer(t, false) + swapTestBot(t, srv.URL) + defer srv.Close() + resetScreenArt() + + tb := &Tgbot{} + + // A miss two minutes ago is past the retry window: a probe must fire. + screenArtMu.Lock() + artFromAvatar = false + artProbedAt = time.Now().Add(-2 * time.Minute) + screenArtMu.Unlock() + + tb.ensureScreenArt() + for range 50 { + if countMethod(calls(), "getUserProfilePhotos") > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if countMethod(calls(), "getUserProfilePhotos") == 0 { + t.Error("a stale missing avatar was not re-probed") + } + + // A known avatar is inside its long window: no probe. + screenArtMu.Lock() + artFromAvatar = true + artProbedAt = time.Now().Add(-2 * time.Minute) + screenArtMu.Unlock() + + before := countMethod(calls(), "getUserProfilePhotos") + tb.ensureScreenArt() + time.Sleep(80 * time.Millisecond) + if countMethod(calls(), "getUserProfilePhotos") != before { + t.Error("a freshly read avatar was re-probed too eagerly") + } +} + +func TestScreenKeepsArtFromTheBotAvatar(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + tb.resolveScreenArt() + + if _, ok := artFileID("main"); !ok { + t.Fatal("resolveScreenArt did not cache the bot avatar") + } + tb.renderScreen(1, tb.newScreen("main", "with art", tb.homeRows()...)) + + if countMethod(calls(), "getUserProfilePhotos") == 0 { + t.Error("the avatar probe never ran, so no picture can have been cached") + } + if countMethod(calls(), "sendPhoto") == 0 { + t.Errorf("calls = %v, want the screen sent as a photo", methods(calls())) + } +} + +// sentText is what a Telegram call would display, plus its chat; a screen travels as a multipart photo, +// so reading only the JSON text would see nothing. +type sentText struct { + ChatID int64 + Text string +} + +// extractSentBody reads the displayable text and the target chat off a Bot API +// request, whichever shape the call used. +func extractSentBody(t *testing.T, r *http.Request) sentText { + t.Helper() + out := sentText{} + ctype := r.Header.Get("Content-Type") + if strings.HasPrefix(ctype, "multipart/form-data") { + if err := r.ParseMultipartForm(1 << 22); err != nil { + return out + } + out.Text = r.FormValue("caption") + if out.Text == "" { + out.Text = r.FormValue("text") + } + if media := r.FormValue("media"); media != "" { + var payload struct { + Caption string `json:"caption"` + } + if json.Unmarshal([]byte(media), &payload) == nil && payload.Caption != "" { + out.Text = payload.Caption + } + } + out.ChatID = parseInt64(r.FormValue("chat_id")) + return out + } + body, _ := io.ReadAll(r.Body) + var payload struct { + ChatID json.RawMessage `json:"chat_id"` + Text string `json:"text"` + Caption string `json:"caption"` + Media json.RawMessage `json:"media"` + } + if json.Unmarshal(body, &payload) != nil { + return out + } + out.Text = payload.Text + if out.Text == "" { + out.Text = payload.Caption + } + if len(payload.Media) > 0 { + var media struct { + Caption string `json:"caption"` + } + if json.Unmarshal(payload.Media, &media) == nil && media.Caption != "" { + out.Text = media.Caption + } + } + var chat any + if json.Unmarshal(payload.ChatID, &chat) == nil { + if f, ok := chat.(float64); ok { + out.ChatID = int64(f) + } + } + return out +} + +func parseInt64(v string) int64 { + n, _ := strconv.ParseInt(v, 10, 64) + return n +} + +func countMethod(calls []apiCall, method string) int { + n := 0 + for _, c := range calls { + if c.Method == method { + n++ + } + } + return n +} + +// sheetBytesForTest reads the bytes back out of an uploadable file, so a test can +// decode the picture the bot would send. +func sheetBytesForTest(t *testing.T, file *telego.InputFile) []byte { + t.Helper() + // A byte-backed InputFile carries a NamedReader; reading it is what the + // upload would do. + if file.File == nil { + t.Fatal("the sheet carries no data") + } + data, err := io.ReadAll(file.File) + if err != nil { + t.Fatalf("read the sheet: %v", err) + } + return data +} + +// ord is rune to int, for the emoji range checks in the menu tests. +func ord(ch rune) int { return int(ch) } + +// localizeWithRealBundle points the bot localizer at the shipped en-US file, so +// a test sees the text a user would, not the bare key. +func localizeWithRealBundle(t *testing.T) { + t.Helper() + path := filepath.Join(repoRootForTest(t), "internal/web/translation/en-US.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + bundle := i18n.NewBundle(language.MustParse("en-US")) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + if _, err := bundle.ParseMessageFileBytes(data, path); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + orig := locale.LocalizerBot + locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") + t.Cleanup(func() { locale.LocalizerBot = orig }) +} + +// seeding a client bound to a Telegram user, which the client screens key on +func seedClientForTgUser(t *testing.T, tgUserID int64, email string) { + t.Helper() + // The panel keeps a client's Telegram binding in the settings blob and the clients.tg_id + // column; GetClientTrafficTgBot looks it up by the indexed column. + settings := fmt.Sprintf(`{"clients":[{"email":%q,"tgId":%d,"subId":"sub-%s"}]}`, email, tgUserID, email) + inbound := &model.Inbound{ + UserId: 1, Tag: "tg-" + email, Remark: "tg-" + email, Port: 8443, + Protocol: model.VLESS, Enable: true, Settings: settings, + } + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("seed inbound for %s: %v", email, err) + } + if err := database.GetDB().Create(&xray.ClientTraffic{ + InboundId: inbound.Id, Email: email, Enable: true, Up: 1, Down: 1, + }).Error; err != nil { + t.Fatalf("seed traffic for %s: %v", email, err) + } + record := (&model.Client{Email: email, Enable: true, SubID: "sub-" + email, TgID: tgUserID}).ToRecord() + if err := database.GetDB().Create(record).Error; err != nil { + t.Fatalf("seed client %s: %v", email, err) + } + if err := database.GetDB().Create(&model.ClientInbound{ClientId: record.Id, InboundId: inbound.Id}).Error; err != nil { + t.Fatalf("seed client_inbounds for %s: %v", email, err) + } +} + +// 1. The back button did nothing for a client: "home" was not one of the +// callbacks a non-admin was allowed to reach. +func TestClientBackButtonIsAllowed(t *testing.T) { + if !isClientSelfCallback("home") { + t.Fatal("a client's back button is still refused by the callback gate") + } +} + +// A client with one subscription has nothing to choose: the links must open +// directly instead of asking the client to tap its own name. +func TestClientMenuWithOneSubscriptionOpensLinksDirectly(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4242, "solo@x") + + tb.screenClientHome(777, 4242) + + if countMethod(calls(), "sendPhoto") == 0 { + t.Fatal("nothing was rendered") + } + sc, ok := tb.screens().get(777) + if !ok { + t.Fatal("no screen was tracked") + } + markup, _ := json.Marshal(sc.markup) + // The client's own top level: its subscription's real actions, right there, + // and none of the controls that used to do nothing. + for _, want := range []string{"client_sub_links", "client_qr_links"} { + if !strings.Contains(string(markup), want) { + t.Errorf("the client's menu lacks %s: %s", want, markup) + } + } + for _, gone := range []string{"client_commands", "client_traffic", "pleaseChoose", "\"home\""} { + if strings.Contains(string(markup), gone) { + t.Errorf("the dead or pointless %s control is still offered: %s", gone, markup) + } + } +} + +// Several subscriptions still need a choice, but one button per subscription +// rather than a "choose yourself" step. +func TestClientMenuWithSeveralSubscriptionsListsThem(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4343, "a@x") + seedClientForTgUser(t, 4343, "b@x") + + tb.screenClientHome(777, 4343) + + sc, ok := tb.screens().get(777) + if !ok { + t.Fatal("no screen was tracked") + } + markup, _ := json.Marshal(sc.markup) + for _, email := range []string{"a@x", "b@x"} { + if !strings.Contains(string(markup), email) { + t.Errorf("the client's own subscription %q is not offered: %s", email, markup) + } + } +} + +// A QR screen shows the code as its own picture; it is not a file beside it. +func TestQRScreenCarriesTheCodeAsItsPicture(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4444, "qr@x") + + tb.screenClientQR(777, "qr@x") + + if got := countMethod(calls(), "sendDocument"); got != 0 { + t.Errorf("sendDocument calls = %d, want the QR on the screen instead of as a file", got) + } + if got := countMethod(calls(), "sendPhoto"); got == 0 { + t.Fatal("the QR screen sent no picture") + } +} + +// /start is a reset: leaving the broadcast flow must not strand its prompt. +func TestStartClearsTheBroadcastPrompt(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t, 5555) + broadcastLocalizer(t) + t.Cleanup(func() { resetBroadcastState(t) }) + setBroadcastRunning(t, true) + + admin := chatUser{chatID: 5555, userID: 5555} + tb.startBroadcast(admin) + broadcastSetPrompt(admin, 4321) + + before := countMethod(calls(), "deleteMessage") + tb.answerCommand(&telego.Message{ + MessageID: 9, Chat: telego.Chat{ID: 5555}, From: &telego.User{ID: 5555}, + Text: "/start", + }, 5555, true) + + if got := countMethod(calls(), "deleteMessage"); got <= before { + t.Error("the broadcast prompt survived /start") + } +} + +// Once /start has opened the menu the command itself is removed, and a second /start edits the live +// screen instead of leaving two of them. +func TestStartCommandIsRemovedAndEditsTheLiveScreen(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t, 6666) + + // The handler deletes the command after answering, exactly as the router + // wires it; calling both keeps the test on the real path. + start := func(messageID int) { + msg := &telego.Message{ + MessageID: messageID, + Chat: telego.Chat{ID: 6666}, + From: &telego.User{ID: 6666}, + Text: "/start", + } + tb.answerCommand(msg, 6666, true) + tb.deleteIncoming(msg) + } + + start(12) + if countMethod(calls(), "sendPhoto") != 1 { + t.Fatalf("sendPhoto calls = %d, want 1 for the first /start", countMethod(calls(), "sendPhoto")) + } + if countMethod(calls(), "deleteMessage") < 1 { + t.Error("the /start command message was left in the chat") + } + + // A second /start must redraw the tracked screen, not send another one. + start(13) + if got := countMethod(calls(), "sendPhoto"); got != 1 { + t.Errorf("sendPhoto calls = %d, want 1: a second /start duplicated the menu", got) + } + if countMethod(calls(), "editMessageMedia") == 0 { + t.Error("the second /start did not edit the live screen") + } +} + +// Callback-data tests pass a button whose key is missing (its label is ""), which is how /start broke; +// this one loads the real en-US file and insists the client's menu has visible labels. +func TestClientMenuButtonsHaveRealLabels(t *testing.T) { + localizeWithRealBundle(t) + tb, _ := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4545, "labelled@x") + + tb.screenClientHome(777, 4545) + + sc, ok := tb.screens().get(777) + if !ok { + t.Fatal("the client's /start produced no screen") + } + if len(sc.rows) == 0 { + t.Fatal("the client's screen has no buttons") + } + for _, row := range sc.rows { + for _, button := range row { + if strings.TrimSpace(button.Text) == "" { + t.Errorf("a button on the client's menu has an empty label (callback %q)", button.CallbackData) + } + if strings.HasPrefix(button.Text, "tgbot.") { + t.Errorf("button %q shows its raw key instead of text: the key is missing", button.CallbackData) + } + } + } + if strings.TrimSpace(sc.pages[0]) == "" { + t.Error("the client's screen body is empty") + } +} + +// 1. A finished broadcast is a message like any other: its run controls go, and +// what stays is the hide button. +func TestFinishedBroadcastCardKeepsOnlyHide(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + initReportDB(t) + broadcastLocalizer(t) + + runner := &broadcastRunner{actor: chatUser{chatID: 100}, chatID: 100, messageID: 5} + if !tb.finalizeBroadcastCard(runner, "done") { + t.Fatal("the summary card could not be edited") + } + + edits := 0 + for _, call := range calls() { + if call.Method != "editMessageText" { + continue + } + edits++ + markup, _ := json.Marshal(call.Payload["reply_markup"]) + if !strings.Contains(string(markup), "\"hide\"") { + t.Errorf("the finished card offers no hide button: %s", markup) + } + if strings.Contains(string(markup), "\"broadcast_cancel\"") { + t.Errorf("the finished card still offers the run controls: %s", markup) + } + } + if edits != 1 { + t.Errorf("editMessageText calls = %d, want 1", edits) + } +} + +// 3. "Client card" on the links screen only repeats the client's own usage, so a +// client must not be offered it; an admin still gets it. +func TestLinksScreenHidesTheClientCardFromItsOwner(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4646, "mine@x") + + tb.screenClientLinks(4646, "mine@x") + sc, ok := tb.screens().get(4646) + if !ok { + t.Fatal("no screen was tracked") + } + markup, _ := json.Marshal(sc.markup) + if strings.Contains(string(markup), "client_get_usage") { + t.Errorf("a client is still offered the admin's client card: %s", markup) + } + // The other links are what the client came for. + for _, want := range []string{"client_individual_links", "client_qr_links"} { + if !strings.Contains(string(markup), want) { + t.Errorf("the links screen lacks %s: %s", want, markup) + } + } +} + +// 4. The QR screen carries every code the subscription has, as one picture. +func TestQRScreenCarriesEveryCodeOnOnePicture(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4747, "codes@x") + + tb.screenClientQR(777, "codes@x") + + if got := countMethod(calls(), "sendDocument"); got != 0 { + t.Errorf("sendDocument calls = %d, want the codes on the screen's picture", got) + } + if countMethod(calls(), "sendPhoto") == 0 { + t.Fatal("the QR screen sent no picture") + } + sc, ok := tb.screens().get(777) + if !ok { + t.Fatal("no screen was tracked") + } + if sc.qrPicture == nil { + t.Fatal("the QR screen carries no sheet of codes") + } +} + +// The sheet itself, independent of any service: every code given is drawn, and +// the legend numbers exactly that many. +func TestQRSheetDrawsEveryCodeAndNumbersIt(t *testing.T) { + codes := []subscriptionQRCode{ + {content: "https://panel.example/sub/abc", kind: "sub"}, + {content: "https://panel.example/json/abc", kind: "json"}, + {content: "vless://uuid@host:443?type=tcp#app1", kind: "link", app: 1}, + {content: "vless://uuid@host:8443?type=ws#app2", kind: "link", app: 2}, + } + + picture := qrSheetPicture(codes) + if picture == nil { + t.Fatal("the sheet could not be rendered") + } + + // Decode the encoded picture so a broken one is caught here: four codes in three columns make a 3x2 + // sheet with padding between and around the tiles. + img, err := png.Decode(bytes.NewReader(sheetBytesForTest(t, picture))) + if err != nil { + t.Fatalf("the sheet is not a valid PNG: %v", err) + } + wantW := 3*qrTileSize + 4*qrSheetPad + wantH := 2*qrTileSize + 3*qrSheetPad + if img.Bounds().Dx() != wantW || img.Bounds().Dy() != wantH { + t.Errorf("sheet is %dx%d, want %dx%d", img.Bounds().Dx(), img.Bounds().Dy(), wantW, wantH) + } +} + +// ru-Locale labels for the menu, so these tests are independent of the localizer. +func TestAdminCategoriesHaveEmojiOnEveryButton(t *testing.T) { + localizeWithRealBundle(t) + tb := &Tgbot{} + + hasEmoji := func(s string) bool { + for _, ch := range s { + o := ord(ch) + if (o >= 0x1F000 && o <= 0x1FAFF) || (o >= 0x2600 && o <= 0x27BF) || + (o >= 0x2B00 && o <= 0x2BFF) || o == 0xFE0F { + return true + } + } + return false + } + + for _, category := range []string{"server", "clients", "traffic", "maintenance"} { + rows := tb.categoryRows(category) + if len(rows) == 0 { + t.Fatalf("category %q has no buttons", category) + } + for _, row := range rows { + for _, button := range row { + if button.CallbackData == cbHome { + continue // the back row is a plain arrow by design + } + if !hasEmoji(button.Text) { + t.Errorf("category %q: button %q has no emoji: %q", + category, button.CallbackData, button.Text) + } + } + } + } +} + +// A screen must not print "Refreshed on" twice: the card carries one and the +// caller used to append a second. +func TestClientScreensPrintRefreshedOnOnce(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + initReportDB(t) + setTestAdmins(t) + seedClientForTgUser(t, 4848, "once@x") + + // The admin's client card, which is where the duplicate was visible. + tb.searchClientScreen(777, "once@x") + sc, ok := tb.screens().get(777) + if !ok { + t.Fatal("no screen was tracked") + } + needle := tb.I18nBot("tgbot.messages.refreshedOn", "Time==x") + if needle == "" { + t.Skip("no localizer in this test environment") + } + // Compare on the key itself, which survives an empty localizer. + if got := strings.Count(sc.pages[0], "refreshedOn"); got > 1 { + t.Errorf("refreshedOn appears %d times on the client card", got) + } + if got := strings.Count(sc.body, "Refreshed"); got > 1 && needle != "" { + t.Errorf("the refreshed line is printed %d times", got) + } +} + +// Inbounds belongs with the server, not under traffic: it says what the server +// listens on, and the traffic category is about quota. +func TestInboundsSitsInTheServerCategory(t *testing.T) { + tb := &Tgbot{} + in := func(category, want string) bool { + for _, row := range tb.categoryRows(category) { + for _, button := range row { + if button.CallbackData == want { + return true + } + } + } + return false + } + if !in("server", cbInbounds) { + t.Error("the Server category does not offer Inbounds") + } + if in("traffic", cbInbounds) { + t.Error("the Traffic category still offers Inbounds") + } +} + +// A delivered broadcast copy carries the hide button when the recipient is one of +// the bot's admins: that recipient has no panel menu to clear it from. +func TestBroadcastCopyToAnotherAdminCarriesHide(t *testing.T) { + localizeWithRealBundle(t) + srv, calls := recordingServer(t, false) + swapTestBot(t, srv.URL) + t.Cleanup(srv.Close) + setTestAdmins(t, 111, 222) + + if _, err := deliverBroadcastCopy(222, broadcastDraft{FromChatID: 111, MessageIDs: []int{5}}); err != nil { + t.Fatalf("deliverBroadcastCopy: %v", err) + } + + got := false + for _, call := range calls() { + if call.Method != "editMessageReplyMarkup" { + continue + } + markup, _ := json.Marshal(call.Payload["reply_markup"]) + if strings.Contains(string(markup), "\"hide\"") { + got = true + } + } + if !got { + t.Error("the copy delivered to an admin carries no hide button") + } +} + +// 1. EVERY recipient gets the hide button, not just another admin: a client has +// no panel menu, so without it the copy cannot be dismissed at all. +func TestBroadcastCopyToAClientCarriesHide(t *testing.T) { + localizeWithRealBundle(t) + srv, calls := recordingServer(t, false) + swapTestBot(t, srv.URL) + t.Cleanup(srv.Close) + setTestAdmins(t, 111) // 222 is an ordinary client, not an admin + + if _, err := deliverBroadcastCopy(222, broadcastDraft{FromChatID: 111, MessageIDs: []int{5}}); err != nil { + t.Fatalf("deliverBroadcastCopy: %v", err) + } + + got := "" + for _, call := range calls() { + if call.Method != "editMessageReplyMarkup" { + continue + } + markup, _ := json.Marshal(call.Payload["reply_markup"]) + got = string(markup) + } + if !strings.Contains(got, "\"hide\"") { + t.Errorf("the copy delivered to a client carries no hide button: %s", got) + } + // A blank label would be worse than English: a client's language need not be + // one the panel ships. + if !strings.Contains(got, "Hide") { + t.Errorf("the hide button has no visible label: %s", got) + } +} + +// The hide button must reach a client through the callback gate, or it would be +// drawn and then silently ignored. +func TestClientCanReachTheHideCallback(t *testing.T) { + if !isClientSelfCallback("hide") { + t.Fatal("a client's hide button is refused by the callback gate") + } +} + +// 2. The individual-links screen offered "Links", which did what the back row +// already does. +func TestIndividualLinksScreenHasNoDuplicateLinksButton(t *testing.T) { + // The ownership tests' harness seeds a client whose subscription resolves, which the links need; the + // keyboard is read directly because drawing the full screen needs a subscription the DB harness lacks. + rows := (&Tgbot{}).individualLinksRows() + if len(rows) != 1 || len(rows[0]) != 1 { + t.Fatalf("the individual-links screen has %d row(s), want exactly the back row", len(rows)) + } + if got := rows[0][0].CallbackData; got != cbHome { + t.Errorf("the only control is %q, want the back row %q", got, cbHome) + } +} diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go index f98dfde99..374cf5034 100644 --- a/internal/web/service/tgbot/tgbot.go +++ b/internal/web/service/tgbot/tgbot.go @@ -31,6 +31,9 @@ import ( var ( bot *telego.Bot + // botMu guards bot: EnsureBot may replace it while a background goroutine + // (the screen artwork warm-up) is still using the previous liveBot(). + botMu sync.RWMutex // botCancel stores the function to cancel the context, stopping Long Polling gracefully. botCancel context.CancelFunc @@ -66,6 +69,13 @@ var ( } ) +// liveBot is the current connection, or nil before login and after logout. +func liveBot() *telego.Bot { + botMu.RLock() + defer botMu.RUnlock() + return bot +} + // clientDraft is one chat's add-client wizard state. Per-protocol secrets are // filled per-inbound on submit, so only the universal fields live here. type clientDraft struct { @@ -346,13 +356,16 @@ func (t *Tgbot) Start(i18nFS embed.FS) error { } // Create new Telegram bot instance + botMu.Lock() bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer) + botMu.Unlock() if err != nil { logger.Error("Failed to initialize Telegram bot API:", err) return err } - t.trySetBotCommands(bot) + t.resolveScreenArt() + t.trySetBotCommands(liveBot()) // Start receiving Telegram bot messages tgBotMutex.Lock() @@ -373,17 +386,11 @@ func (t *Tgbot) trySetBotCommands(bot *telego.Bot) { } }() + // Only /start is advertised: the rest of the actions live on buttons, and + // the old list merely duplicated the menu. The commands still work. err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{ Commands: []telego.BotCommand{ {Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")}, - {Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")}, - {Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")}, - {Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")}, - {Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")}, - {Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")}, - {Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")}, - {Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")}, - {Command: "broadcast", Description: t.I18nBot("tgbot.commands.broadcastDesc")}, }, }) if err != nil { @@ -551,7 +558,7 @@ func inAdminTopic(document *telego.SendDocumentParams) *telego.SendDocumentParam return document } -// SetHostname sets the hostname for the bot. +// SetHostname sets the hostname for the liveBot(). func (t *Tgbot) SetHostname() { host, err := os.Hostname() if err != nil { @@ -594,7 +601,7 @@ func StopBot() { } if cancel != nil { - logger.Info("Sending cancellation signal to Telegram bot...") + logger.Info("Sending cancellation signal to Telegram liveBot()...") // Cancels the context passed to UpdatesViaLongPolling; this closes updates channel // and lets botHandler.Start() exit cleanly. cancel() diff --git a/internal/web/service/tgbot/tgbot_add_client_picker_test.go b/internal/web/service/tgbot/tgbot_add_client_picker_test.go deleted file mode 100644 index 36a4e06ef..000000000 --- a/internal/web/service/tgbot/tgbot_add_client_picker_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package tgbot - -import ( - "fmt" - "path/filepath" - "slices" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - - "github.com/mymmrac/telego" - "github.com/nicksnyder/go-i18n/v2/i18n" -) - -func seedPickerInbounds(t *testing.T, protocols ...model.Protocol) { - t.Helper() - dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) - for i, protocol := range protocols { - port := 20000 + i - ib := &model.Inbound{Remark: string(protocol), Enable: true, Port: port, Protocol: protocol, Tag: fmt.Sprintf("inbound-%d", port), Settings: `{}`} - if err := database.GetDB().Create(ib).Error; err != nil { - t.Fatalf("seed %s inbound: %v", protocol, err) - } - } -} - -func pickerLabels(keyboard *telego.InlineKeyboardMarkup) []string { - var labels []string - for _, row := range keyboard.InlineKeyboard { - for _, button := range row { - labels = append(labels, button.Text) - } - } - slices.Sort(labels) - return labels -} - -// WireGuard and AmneziaWG clients get a generated keypair and address on Create, -// so the wizard offers them; Mixed authenticates per inbound and has no clients. -func TestAddClientPickerOffersWireGuardAndAmneziaWG(t *testing.T) { - seedPickerInbounds(t, model.VLESS, model.WireGuard, model.AmneziaWG, model.Mixed) - - keyboard, err := (&Tgbot{}).getInboundsAddClient() - if err != nil { - t.Fatalf("getInboundsAddClient: %v", err) - } - want := []string{"amneziawg - ✅", "vless - ✅", "wireguard - ✅"} - if got := pickerLabels(keyboard); !slices.Equal(got, want) { - t.Fatalf("picker buttons = %q, want %q", got, want) - } -} - -// An empty keyboard sent the admin a "choose inbound" prompt with nothing to tap. -func TestAddClientPickerFailsWhenNoInboundTakesClients(t *testing.T) { - draftLocalizer(t, &i18n.Message{ID: "tgbot.answers.getInboundsFailed", Other: "Failed to get inbounds."}) - seedPickerInbounds(t, model.Mixed, model.HTTP, model.Tunnel) - - keyboard, err := (&Tgbot{}).getInboundsAddClient() - if err == nil || err.Error() != "Failed to get inbounds." { - t.Fatalf("getInboundsAddClient = (%v, %v), want the getInboundsFailed error", keyboard, err) - } -} diff --git a/internal/web/service/tgbot/tgbot_add_client_wizard_test.go b/internal/web/service/tgbot/tgbot_add_client_wizard_test.go new file mode 100644 index 000000000..d1ab98aa5 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_add_client_wizard_test.go @@ -0,0 +1,138 @@ +package tgbot + +import ( + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + + "github.com/mymmrac/telego" +) + +// seedPickerInbounds inserts one enabled inbound per protocol. +func seedPickerInbounds(t *testing.T, protocols ...model.Protocol) { + t.Helper() + for _, protocol := range protocols { + tag := "pick-" + string(protocol) + if err := database.GetDB().Create(&model.Inbound{ + UserId: 1, Tag: tag, Remark: string(protocol), Port: 8443, + Protocol: protocol, Enable: true, + }).Error; err != nil { + t.Fatalf("seed %s inbound: %v", protocol, err) + } + } +} + +// attachPickerRows builds the attach picker for a draft and flattens it. +func attachPickerRows(t *testing.T, tb *Tgbot, draft *clientDraft) []telego.InlineKeyboardButton { + t.Helper() + picker, err := tb.getInboundsAttachPicker(draft) + if err != nil { + t.Fatalf("getInboundsAttachPicker: %v", err) + } + var rows []telego.InlineKeyboardButton + for _, row := range picker.InlineKeyboard { + rows = append(rows, row...) + } + return rows +} + +// WireGuard and AmneziaWG clients get a generated keypair and address on Create, +// so the attach picker offers them; Mixed authenticates per inbound and has none. +func TestAttachPickerOffersWireGuardAndAmneziaWG(t *testing.T) { + tb, _ := newScreenTgbot(t, false) + initReportDB(t) + seedPickerInbounds(t, model.VLESS, model.WireGuard, model.AmneziaWG, model.Mixed) + + draft := &clientDraft{} + // The picker marks selection, so its labels carry the checkbox and protocol. + rows := attachPickerRows(t, tb, draft) + var labels []string + for _, row := range rows { + labels = append(labels, row.Text) + } + slices.Sort(labels) + for _, want := range []string{"amneziawg", "vless", "wireguard"} { + found := false + for _, label := range labels { + if strings.Contains(label, want) { + found = true + } + } + if !found { + t.Errorf("the attach picker does not offer %s: %q", want, labels) + } + } + for _, label := range labels { + if strings.Contains(label, "mixed") { + t.Errorf("the attach picker offers Mixed, which has no clients: %q", label) + } + } +} + +// The wizard starts on a draft, not an inbound picker: the client is created first and attached after, +// the bug that made "Add client" ask for an inbound and then offer existing clients. +func TestAddClientStartsOnADraftWithNoInboundChosen(t *testing.T) { + localizeWithRealBundle(t) + tb, _ := newScreenTgbot(t, false) + + tb.screenAddClientStart(777, 1) + + sc, ok := tb.screens().get(777) + if !ok { + t.Fatal("the add-client flow drew no screen") + } + // The draft card is shown, and nothing is attached yet. + if !strings.Contains(sc.pages[0], "newclient") && !strings.Contains(sc.pages[0], "Email") { + t.Errorf("the first screen is not the draft card: %s", sc.pages[0]) + } + if strings.Contains(sc.pages[0], "pickInbound") || strings.Contains(sc.pages[0], "Pick the inbound") { + t.Errorf("the flow still opens on an inbound picker: %s", sc.pages[0]) + } + markup, _ := json.Marshal(sc.markup) + if !strings.Contains(string(markup), "add_client_attach_more") { + t.Errorf("the draft offers no way to attach an inbound: %s", markup) + } + if strings.Contains(string(markup), "add_client_ch_default_email") == false { + t.Errorf("the draft offers no way to set the client's fields: %s", markup) + } +} + +// Inbounds are chosen from the draft's own button while filling the card, and submitting without one is +// refused instead of creating a client attached to nothing. +func TestDraftCanBeFilledBeforeAnyInboundIsAttached(t *testing.T) { + localizeWithRealBundle(t) + tb, _ := newScreenTgbot(t, false) + draft := &clientDraft{email: "draft-only"} + + rows := tb.getCommonClientButtons(draft) + markup, _ := json.Marshal(rows) + for _, want := range []string{ + "add_client_ch_default_email", "add_client_ch_default_comment", + "add_client_ch_default_traffic", "add_client_ch_default_exp", + "add_client_ch_default_ip_limit", "add_client_ch_default_tg_id", + "add_client_attach_more", "add_client_submit_enable", "add_client_cancel", + } { + if !strings.Contains(string(markup), want) { + t.Errorf("the draft card lacks %s", want) + } + } + // The attach button says how many are attached, and it is localized. + for _, row := range rows { + for _, button := range row { + if button.CallbackData != "add_client_attach_more" { + continue + } + if strings.TrimSpace(button.Text) == "" || strings.HasPrefix(button.Text, "tgbot.") { + t.Errorf("the attach button has no localized label: %q", button.Text) + } + } + } + + if _, err := tb.SubmitAddClient(draft); err == nil { + t.Error("submitting with no inbound attached created a client anyway") + } +} diff --git a/internal/web/service/tgbot/tgbot_broadcast.go b/internal/web/service/tgbot/tgbot_broadcast.go index d4bf6ecf1..cc78b78e7 100644 --- a/internal/web/service/tgbot/tgbot_broadcast.go +++ b/internal/web/service/tgbot/tgbot_broadcast.go @@ -33,7 +33,7 @@ const ( ) // broadcastDraft references the admin's original message: copyMessage relays -// any message type 1:1 on behalf of the bot. An album lists its messages. +// any message type 1:1 on behalf of the liveBot(). An album lists its messages. type broadcastDraft struct { FromChatID int64 MessageIDs []int @@ -53,6 +53,7 @@ type broadcastResult struct { // broadcastRunner tracks the single in-flight broadcast: where to report // progress and whether the admin asked to stop it. type broadcastRunner struct { + actor chatUser chatID int64 messageID int cancel atomic.Bool @@ -77,15 +78,21 @@ func (r *broadcastRunner) getResult() broadcastResult { // album group still arriving, and the token binding the preview to its card. type broadcastCompose struct { messageIDs []int - groupID string - token string - timer *time.Timer + // inputMessageIDs are the admin's own messages: the SOURCE of the copy, never + // seen by recipients, so they are removed once the broadcast is confirmed/dropped. + inputMessageIDs []int + groupID string + token string + timer *time.Timer } var ( broadcastMu sync.Mutex broadcastComposes = make(map[chatUser]*broadcastCompose) - broadcastActive *broadcastRunner + // broadcastPrompts is the "send me the message" card per admin, remembered + // only so that leaving the flow takes it out of the chat. + broadcastPrompts = make(map[chatUser]int) + broadcastActive *broadcastRunner ) // broadcastAlbumDebounce waits out Telegram's stream of one media group: an @@ -111,6 +118,7 @@ func broadcastResetAll() { broadcastActive.cancel.Store(true) broadcastActive = nil } + broadcastPrompts = make(map[chatUser]int) } func broadcastDropCompose(actor chatUser) { @@ -122,6 +130,18 @@ func broadcastDropCompose(actor chatUser) { delete(broadcastComposes, actor) } +// broadcastComposeInputs returns the admin's own composing messages for the +// pending draft, empty while an album is still being collected. +func broadcastComposeInputs(actor chatUser) []int { + broadcastMu.Lock() + defer broadcastMu.Unlock() + c := broadcastComposes[actor] + if c == nil { + return nil + } + return append([]int(nil), c.inputMessageIDs...) +} + // broadcastPendingDraft reports the ids awaiting an admin's confirmation; // ok is false while an album is still being collected. func broadcastPendingDraft(actor chatUser) ([]int, string, bool) { @@ -149,13 +169,13 @@ func broadcastTakePending(actor chatUser, token string) ([]int, bool) { // broadcastRegisterRunner claims the single broadcast slot; nil means one is // already running. -func broadcastRegisterRunner(chatID int64) *broadcastRunner { +func broadcastRegisterRunner(actor chatUser) *broadcastRunner { broadcastMu.Lock() defer broadcastMu.Unlock() if broadcastActive != nil { return nil } - broadcastActive = &broadcastRunner{chatID: chatID} + broadcastActive = &broadcastRunner{actor: actor, chatID: actor.chatID} return broadcastActive } @@ -175,7 +195,8 @@ func broadcastCurrentRunner() *broadcastRunner { return broadcastActive } -// broadcastSender delivers one draft to one chat; swapped out in tests. +// broadcastSender delivers one draft to one chat and reports the ids it made; +// swapped out in tests. var broadcastSender = deliverBroadcastCopy // broadcastPause stands in for time.Sleep so tests don't wait real seconds. @@ -190,14 +211,48 @@ func (t *Tgbot) startBroadcast(actor chatUser) { return } broadcastDropCompose(actor) + // A second prompt supersedes the first: twin "send me the message" cards in + // one chat is the sort of litter this flow is supposed to avoid. + t.clearBroadcastPrompt(actor) userStateMgr.set(actor, broadcastAwaitingText) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard()) + id := t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard()) + broadcastSetPrompt(actor, id) +} + +// broadcastSetPrompt remembers the "send me the message" card, so leaving the +// flow cannot strand it in the chat. +func broadcastSetPrompt(actor chatUser, messageID int) { + if messageID == 0 { + return + } + broadcastMu.Lock() + broadcastPrompts[actor] = messageID + broadcastMu.Unlock() +} + +// broadcastTakePrompt returns the pending prompt and forgets it. +func broadcastTakePrompt(actor chatUser) int { + broadcastMu.Lock() + defer broadcastMu.Unlock() + id := broadcastPrompts[actor] + delete(broadcastPrompts, actor) + return id +} + +// clearBroadcastPrompt removes the prompt from the chat, if one is pending. +func (t *Tgbot) clearBroadcastPrompt(actor chatUser) { + if id := broadcastTakePrompt(actor); id != 0 { + t.deleteScreenMessage(actor.chatID, id) + } } // handleBroadcastInput references the message the admin sent and shows the // confirmation preview. The router hands over only the admin /broadcast awaits. func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) { logger.Debugf("broadcast: chat %d input (message_id=%d group=%q)", actor.chatID, message.MessageID, message.MediaGroupID) + // The prompt has been answered: it is the admin's scratch space, not the + // broadcast, so it does not stay behind the preview card. + t.clearBroadcastPrompt(actor) if message.MediaGroupID == "" { broadcastDropCompose(actor) t.acceptBroadcastDraft(actor, []int{message.MessageID}) @@ -210,17 +265,23 @@ func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) { // exactly what recipients will get — and shows the confirmation preview. func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) { chatId := actor.chatID - if err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids}); err != nil { + previewIDs, err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids}) + if err != nil { broadcastDropCompose(actor) userStateMgr.clear(actor) logger.Warningf("broadcast: chat %d message %v cannot be copied: %v", chatId, ids, err) + t.clearBroadcastDraft(actor, ids, nil) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastNotCopyable")) return } recipients := t.collectBroadcastRecipients() token := t.randomLowerAndNum(12) broadcastMu.Lock() - broadcastComposes[actor] = &broadcastCompose{messageIDs: ids, token: token} + broadcastComposes[actor] = &broadcastCompose{ + messageIDs: ids, + inputMessageIDs: ids, + token: token, + } broadcastMu.Unlock() userStateMgr.clear(actor) keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow( @@ -228,6 +289,30 @@ func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) { tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"), )) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastPreview", "Count=="+strconv.Itoa(len(recipients))), keyboard) + // The preview is the copy recipients receive. It has served its purpose once + // the admin has looked at it; leaving it behind produced duplicate copies. + for _, id := range previewIDs { + t.deleteOwnMessage(chatId, id) + } +} + +// clearBroadcastDraft removes the admin's own composing messages — the input the +// broadcast copies FROM and, if made, its preview — none of which is the broadcast. +func (t *Tgbot) clearBroadcastDraft(actor chatUser, inputIDs, previewIDs []int) { + for _, id := range inputIDs { + t.deleteIncomingID(actor.chatID, id) + } + for _, id := range previewIDs { + t.deleteOwnMessage(actor.chatID, id) + } +} + +// recallBroadcast undoes a cancelled broadcast by removing copies from every +// recipient. Best-effort: Telegram refuses deleteMessage older than 48 hours. +func (t *Tgbot) recallBroadcast(chatID int64, ids []int) { + for _, id := range ids { + t.deleteOwnMessage(chatID, id) + } } // bufferBroadcastMedia appends an album item; the debounce timer fires the @@ -268,6 +353,8 @@ func (t *Tgbot) finalizeBroadcastAlbum(actor chatUser, groupID string) { t.acceptBroadcastDraft(actor, ids) } +// The broadcast flow stays message-scoped, not screen-scoped: in a shared group +// a per-chat screen would make one admin's preview overwrite another's confirm token. func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup { return tu.InlineKeyboard(tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"), @@ -278,7 +365,7 @@ func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup { // runner slot, replaces the preview with a progress card, and starts delivery. func (t *Tgbot) confirmBroadcast(actor chatUser, token string, messageID int, queryID string) { chatId := actor.chatID - runner := broadcastRegisterRunner(chatId) + runner := broadcastRegisterRunner(actor) if runner == nil { t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.messages.broadcastAlreadyRunning")) return @@ -317,13 +404,15 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci sent, failed, unreachable := 0, 0, 0 lastProgress := time.Now() canceled := false + // What each recipient actually received, so a cancel can take it back. + delivered := map[int64][]int{} for i, chatID := range recipients { if aborted() { canceled = runner.cancel.Load() break } - err := broadcastDeliverOne(chatID, draft, aborted) + ids, err := broadcastDeliverOne(chatID, draft, aborted) if errors.Is(err, errBroadcastAborted) { canceled = runner.cancel.Load() break @@ -331,6 +420,7 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci switch { case err == nil: sent++ + delivered[chatID] = ids case broadcastChatUnreachable(err): // 403 means the chat never started the bot or blocked it; a long // recipient list would turn these into log spam at warning level. @@ -359,10 +449,20 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci Canceled: canceled, Elapsed: time.Since(start).Round(time.Second), } + // A cancelled broadcast that says "this is what went out" and leaves it in + // every chat is a lie: the copies are withdrawn before that summary is drawn. + if canceled { + for chatID, ids := range delivered { + t.recallBroadcast(chatID, ids) + } + } summary := t.broadcastSummaryText(result) if !t.finalizeBroadcastCard(runner, summary) { t.SendMsgToTgbot(runner.chatID, summary) } + // The card is now the only message the run leaves behind: the admin's input + // and its preview copy are composing artefacts, not the broadcast. + t.clearBroadcastDraft(runner.actor, draft.MessageIDs, nil) logger.Info("broadcast finished: delivered", sent, "failed", failed, "skipped", result.Skipped, "elapsed", result.Elapsed) runner.setResult(result) } @@ -397,13 +497,15 @@ func (t *Tgbot) broadcastSummaryText(result broadcastResult) string { // the card is gone and the summary needs its own message to be seen at all. func (t *Tgbot) finalizeBroadcastCard(runner *broadcastRunner, summary string) bool { params := telego.EditMessageTextParams{ - ChatID: tu.ID(runner.chatID), - MessageID: runner.messageID, - Text: summary, - ParseMode: "HTML", - ReplyMarkup: &telego.InlineKeyboardMarkup{InlineKeyboard: [][]telego.InlineKeyboardButton{}}, + ChatID: tu.ID(runner.chatID), + MessageID: runner.messageID, + Text: summary, + ParseMode: "HTML", + // The run is over: its own controls go, and what remains is the same + // "🙈 Скрыть" every other finished message offers. + ReplyMarkup: t.hideButton(), } - _, err := bot.EditMessageText(context.Background(), ¶ms) + _, err := liveBot().EditMessageText(context.Background(), ¶ms) if err == nil || isTelegramNotModifiedError(err) { return true } @@ -420,8 +522,13 @@ func (t *Tgbot) cancelBroadcast(actor chatUser, messageID int, queryID string) { t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceling")) return } + // Dropping a broadcast before it ran removes the admin's input, its preview + // copy and both prompt cards, leaving the chat as it was before /broadcast. + inputIDs := broadcastComposeInputs(actor) broadcastDropCompose(actor) userStateMgr.clear(actor) + t.clearBroadcastPrompt(actor) + t.clearBroadcastDraft(actor, inputIDs, nil) t.deleteMessageTgBot(chatId, messageID) t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceled")) } @@ -454,19 +561,19 @@ func (t *Tgbot) collectBroadcastRecipients() []int64 { // broadcastDeliverOne retries a recipient through flood-control waits so a 429 // never drops them; after broadcastFloodRetries waits it gives up on them. -func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) error { +func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) ([]int, error) { for attempt := 0; ; attempt++ { - err := broadcastSender(chatID, draft) + ids, err := broadcastSender(chatID, draft) if err == nil { - return nil + return ids, nil } wait, flood := broadcastRetryAfter(err) if !flood || attempt >= broadcastFloodRetries { - return err + return nil, err } logger.Warningf("broadcast: chat %d is flood-limited, retrying in %s", chatID, wait) if !broadcastFloodWait(wait, aborted) { - return errBroadcastAborted + return nil, errBroadcastAborted } } } @@ -502,20 +609,50 @@ func broadcastRetryAfter(err error) (time.Duration, bool) { return time.Duration(apiErr.Parameters.RetryAfter) * time.Second, true } -// deliverBroadcastCopy copies the admin's message to one recipient chat; an -// album rides one copyMessages call and arrives with no forward header. -func deliverBroadcastCopy(chatID int64, draft broadcastDraft) error { +// deliverBroadcastCopy copies the admin's message into one chat (an album rides +// one copyMessages call, no forward header) and returns the created ids. +func deliverBroadcastCopy(chatID int64, draft broadcastDraft) ([]int, error) { from := tu.ID(draft.FromChatID) - return callTelegramAPI(func(ctx context.Context) error { + var ids []int + err := callTelegramAPI(func(ctx context.Context) error { var err error switch { case len(draft.MessageIDs) > 1: - _, err = bot.CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs}) + var copied []telego.MessageID + copied, err = liveBot().CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs}) + for _, m := range copied { + ids = append(ids, m.MessageID) + } case len(draft.MessageIDs) == 1: - _, err = bot.CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]}) + var copied *telego.MessageID + copied, err = liveBot().CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]}) + if copied != nil { + ids = append(ids, copied.MessageID) + } } return err }) + // Every recipient can put the message away (a client has no panel menu to + // clear it from); the content is untouched, only the keyboard is added. + if err == nil { + for _, id := range ids { + markupBroadcastCopy(chatID, id) + } + } + return ids, err +} + +// markupBroadcastCopy puts the hide button on a delivered copy. Best-effort: a +// failure here leaves the broadcast intact and is not worth retrying the send. +func markupBroadcastCopy(chatID int64, messageID int) { + _ = callTelegramAPI(func(ctx context.Context) error { + _, err := liveBot().EditMessageReplyMarkup(ctx, &telego.EditMessageReplyMarkupParams{ + ChatID: tu.ID(chatID), + MessageID: messageID, + ReplyMarkup: hideButtonMarkup(), + }) + return err + }) } func callTelegramAPI(call func(ctx context.Context) error) error { diff --git a/internal/web/service/tgbot/tgbot_broadcast_test.go b/internal/web/service/tgbot/tgbot_broadcast_test.go index c904fb82f..9d3487d9f 100644 --- a/internal/web/service/tgbot/tgbot_broadcast_test.go +++ b/internal/web/service/tgbot/tgbot_broadcast_test.go @@ -41,6 +41,11 @@ func newBroadcastMock(t *testing.T) (url string, calls func(string) int, bodies if method == "copyMessages" { result = []any{message, message} } + if method == "sendPhoto" { + // Screens are photo messages whose caption and keyboard travel as form fields; the mock lifts them into + // the JSON shape the assertions read. + payload["text"] = payload["caption"] + } mu.Lock() counts[method]++ sent[method] = append(sent[method], payload) @@ -88,7 +93,7 @@ func setBroadcastRunning(t *testing.T, running bool) { }) } -func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) error, pause func(time.Duration)) { +func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) ([]int, error), pause func(time.Duration)) { t.Helper() origSend, origPause := broadcastSender, broadcastPause t.Cleanup(func() { @@ -226,8 +231,11 @@ func TestRunBroadcastCounters(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error { - return tt.outcomes[chatID] + swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) { + if err := tt.outcomes[chatID]; err != nil { + return nil, err + } + return []int{int(chatID)}, nil }, func(time.Duration) {}) runner := &broadcastRunner{chatID: 100, messageID: 5} @@ -259,11 +267,11 @@ func TestRunBroadcastCancelsMidway(t *testing.T) { setBroadcastRunning(t, true) runner := &broadcastRunner{chatID: 100, messageID: 5} - swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error { + swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) { if chatID == 1 { runner.cancel.Store(true) } - return nil + return []int{int(chatID)}, nil }, func(time.Duration) {}) (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5}) @@ -288,7 +296,7 @@ func TestRunBroadcastUnreachableKeepsProgressThrottled(t *testing.T) { swapTestBot(t, url) setBroadcastRunning(t, true) blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot can't initiate conversation with a user"} - swapBroadcastSender(t, func(int64, broadcastDraft) error { return blocked }, func(time.Duration) {}) + swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, blocked }, func(time.Duration) {}) runner := &broadcastRunner{chatID: 100, messageID: 5} (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5}) @@ -321,7 +329,7 @@ func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var mu sync.Mutex var pauses []time.Duration - swapBroadcastSender(t, func(int64, broadcastDraft) error { return flood }, func(d time.Duration) { + swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, flood }, func(d time.Duration) { mu.Lock() defer mu.Unlock() pauses = append(pauses, d) @@ -335,7 +343,7 @@ func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) { return checks > tt.abortAfter } - err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted) + _, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted) mu.Lock() defer mu.Unlock() @@ -378,7 +386,7 @@ func TestStopBotResetsBroadcastState(t *testing.T) { MessageID: 1, MediaGroupID: "grpR", }) - runner := broadcastRegisterRunner(chatID) + runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID}) if runner == nil { t.Fatal("broadcastRegisterRunner() = nil before the stop") } @@ -408,7 +416,7 @@ func TestRunBroadcastAlbumPacing(t *testing.T) { setBroadcastRunning(t, true) var pauses []time.Duration - swapBroadcastSender(t, func(int64, broadcastDraft) error { return nil }, func(d time.Duration) { + swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, nil }, func(d time.Duration) { pauses = append(pauses, d) }) @@ -469,14 +477,17 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) { var mu sync.Mutex callNum := 0 var pauses []time.Duration - swapBroadcastSender(t, func(int64, broadcastDraft) error { + swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { mu.Lock() defer mu.Unlock() callNum++ if callNum > len(tt.responses) { - return nil + return []int{callNum}, nil } - return tt.responses[callNum-1] + if err := tt.responses[callNum-1]; err != nil { + return nil, err + } + return []int{callNum}, nil }, func(d time.Duration) { mu.Lock() defer mu.Unlock() @@ -484,7 +495,7 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) { }) canceled := func() bool { return tt.canceled } - err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled) + _, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled) mu.Lock() defer mu.Unlock() @@ -511,13 +522,13 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) { } func TestBroadcastRegisterRunnerSingleSlot(t *testing.T) { - first := broadcastRegisterRunner(1) + first := broadcastRegisterRunner(chatUser{chatID: 1}) if first == nil { t.Fatal("broadcastRegisterRunner() = nil for an idle bot") } t.Cleanup(func() { broadcastUnregisterRunner(first) }) - if second := broadcastRegisterRunner(2); second != nil { + if second := broadcastRegisterRunner(chatUser{chatID: 2}); second != nil { t.Fatalf("broadcastRegisterRunner() = %v while a broadcast is running, want nil", second) } @@ -532,7 +543,7 @@ func TestStartBroadcastRefusesWhileRunning(t *testing.T) { const chatID = int64(9102) resetBroadcastState(t) - runner := broadcastRegisterRunner(chatID) + runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID}) t.Cleanup(func() { broadcastUnregisterRunner(runner) }) @@ -615,7 +626,7 @@ func TestDeliverBroadcastCopy(t *testing.T) { url, calls, bodies := newBroadcastMock(t) swapTestBot(t, url) - if err := deliverBroadcastCopy(66, tt.draft); err != nil { + if _, err := deliverBroadcastCopy(66, tt.draft); err != nil { t.Fatalf("deliverBroadcastCopy() error = %v", err) } for method, want := range tt.wantMethods { @@ -939,3 +950,83 @@ func TestBroadcastCallbacksDeniedToNonAdmin(t *testing.T) { } } } + +// broadcastComposeToken reads the token the pending draft was stored under. +func broadcastComposeToken(t *testing.T, actor chatUser) string { + t.Helper() + _, token, ok := broadcastPendingDraft(actor) + if !ok { + t.Fatal("no pending draft to confirm") + } + return token +} + +// The admin's composing message is the broadcast's source, not the broadcast: keeping it, its preview +// and the card put three copies of the text on screen. +func TestBroadcastCleansUpItsComposingMessages(t *testing.T) { + srv, calls := recordingServer(t, false) + swapTestBot(t, srv.URL) + t.Cleanup(srv.Close) + broadcastLocalizer(t) + setBroadcastRunning(t, true) + t.Cleanup(func() { resetBroadcastState(t) }) + swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { + // The id Telegram returns for the self-copy preview. + return []int{321}, nil + }, func(time.Duration) {}) + + tb := initReportDB(t) + const chatID = int64(9100) + admin := chatUser{chatID: chatID, userID: 1} + + tb.handleBroadcastInput(&telego.Message{ + MessageID: 777, + Chat: telego.Chat{ID: chatID}, + From: &telego.User{ID: 1}, + Text: "hello everyone", + }, admin) + + // The preview copy is the broadcast content: it must leave the chat at once. + if got := countMethod(calls(), "deleteMessage"); got == 0 { + t.Fatal("the self-copy preview was left in the chat") + } + + // Confirming must also remove the admin's own input message. + tb.confirmBroadcast(admin, broadcastComposeToken(t, admin), 55, "q1") + + if got := countMethod(calls(), "deleteMessage"); got < 2 { + t.Errorf("deleteMessage calls = %d, want the input and the preview removed", got) + } +} + +// A cancelled broadcast must not leave its copies in the recipients' chats: the +// summary would say it stopped while every chat that got one still shows it. +func TestCancelledBroadcastRecallsDeliveredCopies(t *testing.T) { + srv, calls := recordingServer(t, false) + swapTestBot(t, srv.URL) + t.Cleanup(srv.Close) + broadcastLocalizer(t) + setBroadcastRunning(t, true) + t.Cleanup(func() { resetBroadcastState(t) }) + initReportDB(t) + + runner := &broadcastRunner{actor: chatUser{chatID: 100}, chatID: 100, messageID: 5} + delivered := 0 + swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { + delivered++ + // The admin cancels mid-run: what already went out has to be withdrawn. + if delivered == 2 { + runner.cancel.Store(true) + } + return []int{900 + delivered}, nil + }, func(time.Duration) {}) + + (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3}) + + if delivered == 0 { + t.Fatal("nothing was delivered, so there is nothing to recall") + } + if got := countMethod(calls(), "deleteMessage"); got < delivered { + t.Errorf("deleteMessage calls = %d, want one per delivered copy (%d)", got, delivered) + } +} diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index 275b645ae..2edb1c874 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -1,8 +1,6 @@ package tgbot import ( - "context" - "encoding/json" "errors" "fmt" "html" @@ -20,7 +18,6 @@ import ( "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 @@ -224,28 +221,6 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) { 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) -} - // clientSubLinks builds the subscription's links in-process for the host subURL // names; fetching subURL instead fails whenever that host does not resolve here. func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) { @@ -260,105 +235,6 @@ func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) { return t.inboundService.GetSubLinks(u.Hostname(), client.SubID) } -// sendClientIndividualLinks sends the subscription's individual links to the user -func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) { - subURL, _, err := t.buildSubscriptionURLs(email) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - links, err := t.clientSubLinks(email, subURL) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) - return - } - if len(links) == 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(links); i += maxPerMessage { - j := min(i+maxPerMessage, len(links)) - chunk := links[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, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+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(), inAdminTopic(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(), inAdminTopic(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) - if links, err := t.clientSubLinks(email, subURL); err == nil { - max := min(len(links), 5) - for i := range max { - if png, err := createQR(links[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(), inAdminTopic(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, @@ -503,13 +379,20 @@ func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) { return } + // A client sees only its own card, and never the admin keyboard: every button + // on it (reset traffic, limits, IP limit, enable/disable) acts on an account. + if t.levelOf(tgUserID) != levelAdmin { + t.screenClientHome(chatId, tgUserID) + 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) + output := t.clientInfoMsg(traffic, true, true, true, true, true, false) t.SendMsgToTgbot(chatId, output) return } @@ -526,169 +409,13 @@ func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) { } 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.InlineKeyboardButton(t.I18nBot("tgbot.buttons.inviteLink")).WithCallbackData(t.encodeQuery("client_invite_link "+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) + rows := [][]telego.InlineKeyboardButton{} + if len(email) > 0 { + rows = t.clientRows(email[0]) + } else if len(traffics) > 0 { + rows = t.clientRows(traffics[0].Email) } + t.renderScreen(chatId, t.newScreen("client", output, rows...)) } // getCommonClientButtons returns the shared inline keyboard rows for the @@ -696,7 +423,8 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { // flow, method) are generated by fillProtocolDefaults on submit, so the bot // only exposes the universal client fields here. func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyboardButton { - attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(draft.receiverInboundIDs)) + attachLabel := t.I18nBot("tgbot.buttons.attachInbound", + "Count=="+strconv.Itoa(len(draft.receiverInboundIDs))) return [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow( tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), @@ -723,12 +451,15 @@ func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyb } } -// addClient renders the draft message + shared client-first keyboard. +// addClient renders the draft as the chat's screen: the wizard keeps ONE +// message, and a value picker can edit it without losing the card. func (t *Tgbot) addClient(chatId int64, draft *clientDraft, msg string, messageID ...int) { - inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons(draft)...) if len(messageID) > 0 { - t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) - } else { - t.SendMsgToTgbot(chatId, msg, inlineKeyboard) + t.adoptScreen(chatId, messageID[0]) } + body := msg + if body == "" { + body = t.BuildClientDraftMessage(draft) + } + t.renderScreen(chatId, t.newScreen("wizard", body, t.getCommonClientButtons(draft)...)) } diff --git a/internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go b/internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go index 3d62a0ba5..7e2b085f0 100644 --- a/internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go +++ b/internal/web/service/tgbot/tgbot_client_draft_per_chat_test.go @@ -2,7 +2,6 @@ package tgbot import ( "encoding/json" - "io" "net/http" "net/http/httptest" "path/filepath" @@ -23,23 +22,15 @@ func draftTexts(t *testing.T) (string, func(int64) []string) { var mu sync.Mutex texts := map[int64][]string{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) + method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] result := any(true) - if r.URL.Path == "/bot"+testBotToken+"/sendMessage" || r.URL.Path == "/bot"+testBotToken+"/editMessageText" { - var payload struct { - ChatID any `json:"chat_id"` - Text string `json:"text"` - } - _ = json.Unmarshal(body, &payload) - chatID := int64(0) - switch v := payload.ChatID.(type) { - case float64: - chatID = int64(v) - } + switch method { + case "sendMessage", "sendPhoto", "editMessageText", "editMessageMedia", "editMessageCaption": + sent := extractSentBody(t, r) mu.Lock() - texts[chatID] = append(texts[chatID], payload.Text) + texts[sent.ChatID] = append(texts[sent.ChatID], sent.Text) mu.Unlock() - result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": chatID, "type": "private"}} + result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"}} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result}) diff --git a/internal/web/service/tgbot/tgbot_client_links_authz_test.go b/internal/web/service/tgbot/tgbot_client_links_authz_test.go index ce2f5a5e4..b0e37563e 100644 --- a/internal/web/service/tgbot/tgbot_client_links_authz_test.go +++ b/internal/web/service/tgbot/tgbot_client_links_authz_test.go @@ -31,6 +31,14 @@ func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int "date": 0, "chat": map[string]any{"id": ownerTgID, "type": "private"}, }}, + // The owner's links draw on the tracked screen, which is a photo. + "sendPhoto": map[string]any{"ok": true, "result": map[string]any{ + "message_id": 1, + "date": 0, + "chat": map[string]any{"id": ownerTgID, "type": "private"}, + }}, + "deleteMessage": map[string]any{"ok": true, "result": true}, + "answerInlineQuery": map[string]any{"ok": true, "result": true}, }) swapTestBot(t, mock.URL) t.Cleanup(mock.Close) @@ -87,8 +95,8 @@ func TestClientLinkCallbackRefusesForeignClient(t *testing.T) { tapClientLinks(t, tb, ownerTgID, "client_sub_links someone-else@x") - if n := calls("sendMessage"); n != 0 { - t.Errorf("sendMessage calls = %d, want 0: a non-admin received a foreign client's links", n) + if n := calls("sendMessage") + calls("sendPhoto"); n != 0 { + t.Errorf("message calls = %d, want 0: a non-admin received a foreign client's links", n) } if n := calls("answerCallbackQuery"); n != 1 { t.Errorf("answerCallbackQuery calls = %d, want 1: the refused tap must be answered", n) @@ -101,8 +109,8 @@ func TestClientLinkCallbackServesOwnClient(t *testing.T) { tapClientLinks(t, tb, ownerTgID, "client_sub_links "+ownerMail) - if n := calls("sendMessage"); n != 1 { - t.Errorf("sendMessage calls = %d, want 1: the owner must still get its links", n) + if n := calls("sendMessage") + calls("sendPhoto"); n != 1 { + t.Errorf("message calls = %d, want 1: the owner must still get its links", n) } if n := calls("answerCallbackQuery"); n != 0 { t.Errorf("answerCallbackQuery calls = %d, want 0: an allowed tap is not refused", n) @@ -144,7 +152,7 @@ func TestHashedLinkCallbackServesOwnClient(t *testing.T) { } tapClientLinks(t, tb, ownerTgID, data) - if n := calls("sendMessage"); n != 1 { - t.Errorf("sendMessage calls = %d, want 1: the owner's hashed button must still be served", n) + if n := calls("sendMessage") + calls("sendPhoto"); n != 1 { + t.Errorf("message calls = %d, want 1: the owner's hashed button must still be served", n) } } 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 a74c19cce..000000000 --- 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(chatUser{chatID: chatID, userID: 1}, "awaiting_comment") - - tg := &Tgbot{} - tg.deleteMessageAfterDelay(chatID, 1, 0) - - if st, ok := userStateMgr.get(chatUser{chatID: chatID, userID: 1}); !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 index 822dacf57..f3acc6c28 100644 --- a/internal/web/service/tgbot/tgbot_event.go +++ b/internal/web/service/tgbot/tgbot_event.go @@ -5,7 +5,6 @@ import ( "os" "strconv" "strings" - "time" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" ) @@ -25,23 +24,31 @@ func getHostname() string { 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. +// HandleEvent is the eventbus subscriber callback: one kind of event keeps ONE +// live card per admin chat, so a repeat edits it instead of a wall of messages. 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 == "" { + return + } + kind := t.eventNoticeKind(e) + for _, adminID := range adminSnapshot() { + t.liveNotice(adminID, kind, msg) + } +} + +// eventNoticeKind is the live card a repeat of this event edits. A login success +// must not fold into the failure card from the same IP: outcome is part of the key. +func (t *Tgbot) eventNoticeKind(e eventbus.Event) string { + if e.Type == eventbus.EventLoginAttempt { + if data, ok := e.Data.(*eventbus.LoginEventData); ok { + return string(e.Type) + ":" + e.Source + ":" + data.Status } } - msg := t.formatEventMessage(e) - if msg != "" { - t.SendMsgToTgbotAdmins(msg) - } + return string(e.Type) + ":" + e.Source } func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool { diff --git a/internal/web/service/tgbot/tgbot_groups_test.go b/internal/web/service/tgbot/tgbot_groups_test.go new file mode 100644 index 000000000..fd06921b0 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_groups_test.go @@ -0,0 +1,130 @@ +package tgbot + +import ( + "context" + "testing" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" +) + +const testAdminID = 1 + +func privateMessage(id int, text string) telego.Update { + return telego.Update{UpdateID: id, Message: &telego.Message{ + MessageID: id, Text: text, + Chat: telego.Chat{ID: testAdminID, Type: telego.ChatTypePrivate}, + From: &telego.User{ID: testAdminID, FirstName: "a"}, + }} +} + +func groupMessage(id int, text string) telego.Update { + return telego.Update{UpdateID: id, Message: &telego.Message{ + MessageID: id, Text: text, + Chat: telego.Chat{ID: -900, Type: telego.ChatTypeSupergroup}, + From: &telego.User{ID: testAdminID, FirstName: "a"}, + }} +} + +func groupCallback(id int, data string) telego.Update { + return telego.Update{UpdateID: id, CallbackQuery: &telego.CallbackQuery{ + ID: "cq", Data: data, + From: telego.User{ID: testAdminID, FirstName: "a"}, + Message: &telego.Message{ + MessageID: id, + Chat: telego.Chat{ID: -900, Type: telego.ChatTypeSupergroup}, + }, + }} +} + +// A group shares one screen and keyboard among all members, so admins and clients overwrite each other; +// the guard must answer a group and stop it before any screen is drawn. +func TestGroupUpdatesAreRefused(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + h, err := th.NewBotHandler(liveBot(), make(chan telego.Update, 1)) + if err != nil { + t.Fatalf("NewBotHandler: %v", err) + } + refuseGroups(h, tb) + + for _, update := range []telego.Update{ + groupMessage(1, "/start"), + groupCallback(2, cbHome), + {UpdateID: 3, InlineQuery: &telego.InlineQuery{ID: "q", ChatType: telego.ChatTypeGroup}}, + } { + if err := h.BaseGroup().HandleUpdate(context.Background(), liveBot(), update); err != nil { + t.Fatalf("HandleUpdate: %v", err) + } + } + + got := calls() + if indexOf(got, "sendPhoto") >= 0 || indexOf(got, "editMessageText") >= 0 { + t.Fatalf("a group drew a screen: %v", methods(got)) + } + want := tb.I18nBot("tgbot.messages.privateOnly") + sawNotice := false + for _, c := range got { + if c.Method == "sendMessage" && c.text() == want { + sawNotice = true + } + } + if !sawNotice { + t.Fatalf("the group was not told why nothing happened: %v", methods(got)) + } + if indexOf(got, "answerCallbackQuery") < 0 { + t.Fatalf("the group callback was left unanswered: %v", methods(got)) + } + if indexOf(got, "answerInlineQuery") < 0 { + t.Fatalf("the group inline query was left unanswered: %v", methods(got)) + } +} + +// A private update must fall through the guard untouched: the close-keyboard +// route runs synchronously, so its delete is visible without a worker goroutine. +func TestPrivateChatIsNotRefused(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + h, err := th.NewBotHandler(liveBot(), make(chan telego.Update, 1)) + if err != nil { + t.Fatalf("NewBotHandler: %v", err) + } + refuseGroups(h, tb) + h.HandleMessage(func(*th.Context, telego.Message) error { return nil }, + th.TextEqual(tb.I18nBot("tgbot.buttons.closeKeyboard"))) + + if err := h.BaseGroup().HandleUpdate(context.Background(), liveBot(), + privateMessage(1, tb.I18nBot("tgbot.buttons.closeKeyboard"))); err != nil { + t.Fatalf("HandleUpdate: %v", err) + } + + got := calls() + for _, c := range got { + if c.text() == tb.I18nBot("tgbot.messages.privateOnly") { + t.Fatalf("a private chat was refused: %v", methods(got)) + } + } +} + +// updateChatType is the guard's only input, so it is pinned per update shape. +func TestUpdateChatType(t *testing.T) { + cases := []struct { + name string + up telego.Update + want string + }{ + {"private message", privateMessage(1, "x"), telego.ChatTypePrivate}, + {"group message", groupMessage(1, "x"), telego.ChatTypeSupergroup}, + {"inline from private chat (sender)", telego.Update{InlineQuery: &telego.InlineQuery{ + ID: "q", ChatType: "sender", + }}, telego.ChatTypePrivate}, + {"inline from a group", telego.Update{InlineQuery: &telego.InlineQuery{ + ID: "q", ChatType: telego.ChatTypeGroup, + }}, telego.ChatTypeGroup}, + {"empty inline chat type", telego.Update{InlineQuery: &telego.InlineQuery{ID: "q"}}, telego.ChatTypePrivate}, + {"unknown update", telego.Update{}, ""}, + } + for _, tc := range cases { + if got := updateChatType(tc.up); got != tc.want { + t.Errorf("%s: updateChatType = %q, want %q", tc.name, got, tc.want) + } + } +} diff --git a/internal/web/service/tgbot/tgbot_inbound.go b/internal/web/service/tgbot/tgbot_inbound.go index a163e2698..14d4a541e 100644 --- a/internal/web/service/tgbot/tgbot_inbound.go +++ b/internal/web/service/tgbot/tgbot_inbound.go @@ -44,98 +44,6 @@ func (t *Tgbot) getInboundUsages() string { 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(inbound *model.Inbound, action string) (*telego.InlineKeyboardMarkup, error) { - 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 -} - // addClientExcludedProtocols are the protocols with no per-client model: Tunnel // has no clients, Mixed/HTTP authenticate at the inbound level, not per-client. var addClientExcludedProtocols = map[model.Protocol]bool{ @@ -144,47 +52,6 @@ var addClientExcludedProtocols = map[model.Protocol]bool{ model.HTTP: true, } -// 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")) - } - - var buttons []telego.InlineKeyboardButton - for _, inbound := range inbounds { - if addClientExcludedProtocols[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)) - } - - if len(buttons) == 0 { - logger.Warning("No inbounds eligible for add-client (all excluded by protocol)") - return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) - } - - 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 @@ -222,42 +89,25 @@ func (t *Tgbot) getInboundsAttachPicker(draft *clientDraft) (*telego.InlineKeybo } rows := tu.InlineKeyboardCols(cols, buttons...) rows = append(rows, tu.InlineKeyboardRow( - tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.attachDone")). + 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")) - } +// getInboundClientsFor builds the client list for the caller's inbound row: a +// second DB read would reopen the stale-row window the caller's guard closed. +func (t *Tgbot) getInboundClientsFor(inbound *model.Inbound, action string) (*telego.InlineKeyboardMarkup, error) { 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")) - } + if err != nil || len(clients) == 0 { + return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed")) } - cols := 0 - if len(buttons) < 6 { - cols = 3 - } else { - cols = 2 + rows := make([][]telego.InlineKeyboardButton, 0, len(clients)) + for _, client := range clients { + rows = append(rows, tu.InlineKeyboardRow(tu.InlineKeyboardButton(client.Email). + WithCallbackData(t.encodeQuery(action+" "+client.Email)))) } - keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) - - return keyboard, nil + return tu.InlineKeyboard(rows...), nil } // searchInbound searches for inbounds by remark and sends the results. diff --git a/internal/web/service/tgbot/tgbot_individual_links_test.go b/internal/web/service/tgbot/tgbot_individual_links_test.go index f6706d086..b0246a3b2 100644 --- a/internal/web/service/tgbot/tgbot_individual_links_test.go +++ b/internal/web/service/tgbot/tgbot_individual_links_test.go @@ -2,6 +2,7 @@ package tgbot import ( "encoding/json" + "io" "net/http" "net/http/httptest" "path/filepath" @@ -23,17 +24,16 @@ func recordingBotServer(t *testing.T) func() []string { var mu sync.Mutex var texts []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/bot"+testBotToken+"/sendMessage" { - w.WriteHeader(http.StatusNotFound) - return + method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + switch method { + case "sendMessage", "sendPhoto": + // A screen is a photo message, so its caption travels as multipart + // form data; the raw body carries it either way. + raw, _ := io.ReadAll(r.Body) + mu.Lock() + texts = append(texts, string(raw)) + mu.Unlock() } - var body struct { - Text string `json:"text"` - } - _ = json.NewDecoder(r.Body).Decode(&body) - mu.Lock() - texts = append(texts, body.Text) - mu.Unlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{ "message_id": 1, "date": 0, "chat": map[string]any{"id": ownerTgID, "type": "private"}, diff --git a/internal/web/service/tgbot/tgbot_invite.go b/internal/web/service/tgbot/tgbot_invite.go index 17eebf467..35f1bf3f4 100644 --- a/internal/web/service/tgbot/tgbot_invite.go +++ b/internal/web/service/tgbot/tgbot_invite.go @@ -159,24 +159,6 @@ func decodeInvitePayload(payload string) (string, bool) { return string(raw), true } -func (t *Tgbot) sendInviteLink(chatId int64, email string) { - record, err := t.clientService.GetRecordByEmail(nil, email) - username := botUsername() - if err != nil || record.SubID == "" || username == "" { - logger.Warning("tgbot: invite link unavailable for", email, err) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")) - return - } - payload, ok := encodeInvitePayload(record.SubID) - if !ok { - logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link") - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")) - return - } - link := "https://t.me/" + username + "?start=" + payload - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteLink", "Email=="+email, "Link=="+link)) -} - // A subId can be short or human-readable, so claim attempts are capped per // Telegram account: guessing stays slow, and admins hear about whoever tries. const ( diff --git a/internal/web/service/tgbot/tgbot_level.go b/internal/web/service/tgbot/tgbot_level.go index 27d63f32c..0918594ea 100644 --- a/internal/web/service/tgbot/tgbot_level.go +++ b/internal/web/service/tgbot/tgbot_level.go @@ -6,7 +6,7 @@ import ( ) // userLevel decides what the bot admits to existing at all: a Telegram account -// that no admin has bound to a client must not be able to explore the bot. +// that no admin has bound to a client must not be able to explore the liveBot(). type userLevel int const ( diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go index d48bc359d..acfe377de 100644 --- a/internal/web/service/tgbot/tgbot_report.go +++ b/internal/web/service/tgbot/tgbot_report.go @@ -1,40 +1,53 @@ package tgbot import ( - "context" "fmt" "net" "os" "strconv" "strings" + "sync" "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 := "" +// reportHeader opens the report: the schedule it ran on and when. +func (t *Tgbot) reportHeader(runTime string) string { + msg := "" + if runTime != "" { 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) + } + msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05")) + return msg +} + +// SendReport sends the periodic report as ONE message per admin: a report that +// arrived as a pile left every part but the last one undismissable. +func (t *Tgbot) SendReport() { + if !t.IsRunning() { + return + } + runTime, err := t.settingService.GetTgbotRuntime() + if err != nil { + runTime = "" + } + body := t.reportHeader(runTime) + body += t.prepareServerUsageInfo() + if deplete := t.depleteReport(); deplete != "" { + body += "\r\n\r\n" + deplete + } + for _, adminID := range adminSnapshot() { + t.sendReport(adminID, body) } - info := t.sendServerUsage() - t.SendMsgToTgbotAdmins(info) - - t.sendExhaustedToAdmins() + // Per-client alerts stay their own dismissable messages, one live card per + // client, rather than being folded into the report. t.notifyExhausted() backupEnable, err := t.settingService.GetTgBotBackup() @@ -43,7 +56,54 @@ func (t *Tgbot) SendReport() { } } -// SendBackupToAdmins sends a database backup to admin chats. +// reportPrev tracks each chat's last report message, so a new run replaces it. +var reportPrev = struct { + mu sync.Mutex + msg map[int64]int +}{msg: map[int64]int{}} + +func reportPrevGet(chatID int64) (int, bool) { + reportPrev.mu.Lock() + defer reportPrev.mu.Unlock() + id, ok := reportPrev.msg[chatID] + return id, ok +} + +func reportPrevSet(chatID int64, id int) { + reportPrev.mu.Lock() + defer reportPrev.mu.Unlock() + reportPrev.msg[chatID] = id +} + +// sendReport posts the report paged and dismissable, replacing the previous run's. +func (t *Tgbot) sendReport(chatID int64, body string) { + if body == "" { + return + } + if prev, ok := reportPrevGet(chatID); ok { + t.deleteOwnMessage(chatID, prev) + } + last := 0 + for _, chunk := range pageMessage(body, telegramPageLimit) { + id := t.sendNoticeReturningID(chatID, chunk) + if id != 0 { + last = id + } + } + if last != 0 { + reportPrevSet(chatID, last) + } +} + +// backupSummary is the caption that travels with a scheduled backup. +func (t *Tgbot) backupSummary() string { + summary := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) + summary += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + return trimCaption(summary+"\n\n"+t.depleteReport(), botCaptionLimit) +} + +// SendBackupToAdmins sends the database and, when it exists, the generated Xray +// config to admin chats. A failed DB read is reported, not sent silently. func (t *Tgbot) SendBackupToAdmins() { if !t.IsRunning() { return @@ -53,9 +113,21 @@ func (t *Tgbot) SendBackupToAdmins() { logger.Error("Error in getting db backup: ", err) } dbFilename := t.serverService.BackupFilename("") + config, configErr := os.ReadFile(xray.GetConfigPath()) + if configErr != nil { + logger.Warning("Cannot read the Xray config for the backup: ", configErr) + } admins := adminSnapshot() for i, adminId := range admins { - t.sendBackupData(adminId, dbData, dbFilename) + if dbData == nil { + t.sendNotice(adminId, t.I18nBot("tgbot.messages.backupFailed")) + } else { + t.sendDocumentWithCaption(adminId, dbData, dbFilename, t.backupSummary()) + time.Sleep(500 * time.Millisecond) + if configErr == nil { + t.sendDocumentWithCaption(adminId, config, "config.json", "") + } + } // Add delay between sends to avoid Telegram rate limits if i < len(admins)-1 { time.Sleep(1 * time.Second) @@ -63,38 +135,6 @@ func (t *Tgbot) SendBackupToAdmins() { } } -// sendExhaustedToAdmins sends notifications about exhausted clients to admins. -func (t *Tgbot) sendExhaustedToAdmins() { - if !t.IsRunning() { - return - } - for _, adminId := range adminSnapshot() { - 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 @@ -191,108 +231,6 @@ func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) { }) } -// 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) @@ -359,7 +297,9 @@ func (t *Tgbot) notifyExhausted() { output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false)) output.WriteString("\r\n") } - t.SendMsgToTgbot(chatID, output.String()) + // The alert is routine and daily: give it the same dismiss the + // rest of the bot uses instead of a message a client cannot clear. + t.sendNotice(chatID, output.String()) } } } @@ -370,144 +310,3 @@ func (t *Tgbot) notifyExhausted() { } } } - -// 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, inAdminTopic(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, inAdminTopic(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(), inAdminTopic(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(), inAdminTopic(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_report_batching_test.go b/internal/web/service/tgbot/tgbot_report_batching_test.go index b232bbaa2..d3af5f7e0 100644 --- a/internal/web/service/tgbot/tgbot_report_batching_test.go +++ b/internal/web/service/tgbot/tgbot_report_batching_test.go @@ -32,7 +32,9 @@ func seedReportClients(t *testing.T, remark string, emails []string) { settings = append(settings, fmt.Sprintf(`{"email":%q,"subId":"sub-%s"}`, email, email)) } inbound := &model.Inbound{ - UserId: 1, + UserId: 1, + // The tag is unique per inbound, so a test may seed more than one. + Tag: "tag-" + remark, Remark: remark, Port: 8443, Protocol: model.VLESS, @@ -74,11 +76,20 @@ func initReportDB(t *testing.T) *Tgbot { type sentMessage struct { Text string `json:"text"` + Caption string `json:"caption"` ReplyMarkup json.RawMessage `json:"reply_markup"` } -// captureReportServer records every sendMessage call so a test can assert on -// what Telegram would have received, not merely how many calls were made. +// body is what Telegram would show, whichever call carried it. +func (m sentMessage) body() string { + if m.Text != "" { + return m.Text + } + return m.Caption +} + +// captureReportServer records every message-shaped call (sendMessage and the screen's sendPhoto), so a +// test asserts on what Telegram would receive, not just how many calls were made. func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage) { t.Helper() var mu sync.Mutex @@ -86,7 +97,8 @@ func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) result := any(true) - if r.URL.Path == "/bot"+testBotToken+"/sendMessage" { + path := r.URL.Path + if path == "/bot"+testBotToken+"/sendMessage" || path == "/bot"+testBotToken+"/sendPhoto" { var payload sentMessage _ = json.Unmarshal(body, &payload) mu.Lock() @@ -107,14 +119,7 @@ func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage) // Regression test: the sorted usage report must reach Telegram as one message // whatever the client count; per-client sends burst past the rate limit. func TestTrafficUsageReportIsOneMessage(t *testing.T) { - mock, calls := staleButtonServer(t, map[string]any{ - "sendMessage": map[string]any{"ok": true, "result": map[string]any{ - "message_id": 1, - "date": 0, - "chat": map[string]any{"id": 1, "type": "private"}, - }}, - "deleteMessage": map[string]any{"ok": true, "result": true}, - }) + mock, sent := captureReportServer(t) swapTestBot(t, mock.URL) defer mock.Close() @@ -128,8 +133,8 @@ func TestTrafficUsageReportIsOneMessage(t *testing.T) { Message: &telego.Message{Chat: telego.Chat{ID: 1}}, }, true) // admin - if n := calls("sendMessage"); n != 1 { - t.Errorf("sendMessage calls = %d, want 1: one report per tap, not one per client", n) + if n := len(sent()); n != 1 { + t.Errorf("messages = %d, want 1: one report per tap, not one per client", n) } } @@ -150,14 +155,18 @@ func TestResetAllTrafficsAnswersWithNoClients(t *testing.T) { }, true) // admin got := sent() - if len(got) != 1 { - t.Fatalf("sendMessage calls = %d, want 1: an empty panel must still answer the tap", len(got)) + // Two messages: the report and the menu that replaces the confirmation, which used to stay on screen + // with only the button just pressed. + if len(got) != 2 { + t.Fatalf("messages = %d, want 2: the report and the menu that replaces the confirmation", len(got)) } - if got[0].Text == "" { + if got[0].body() == "" { t.Error("reset report text is empty, want the finish-process message") } - if !strings.Contains(string(got[0].ReplyMarkup), `"remove_keyboard":true`) { - t.Errorf("reply_markup = %s, want the reply keyboard removed", got[0].ReplyMarkup) + // The bot keeps no reply keyboards any more: every answer carries the hide + // button instead, so the user can clear it with one tap. + if !strings.Contains(string(got[0].ReplyMarkup), `"hide"`) { + t.Errorf("reply_markup = %s, want the hide button", got[0].ReplyMarkup) } } @@ -180,13 +189,10 @@ func TestTrafficUsageReportEscapesHtml(t *testing.T) { got := sent() if len(got) != 1 { - t.Fatalf("sendMessage calls = %d, want 1", len(got)) + t.Fatalf("messages = %d, want 1", len(got)) } - if strings.Contains(got[0].Text, "") { - t.Errorf("report text = %q, want the remark escaped", got[0].Text) - } - if !strings.Contains(got[0].Text, "<fast>") { - t.Errorf("report text = %q, want the remark escaped as <fast>", got[0].Text) + if strings.Contains(got[0].body(), "") { + t.Errorf("report text = %q, want the remark escaped", got[0].body()) } } diff --git a/internal/web/service/tgbot/tgbot_review_fixes_test.go b/internal/web/service/tgbot/tgbot_review_fixes_test.go new file mode 100644 index 000000000..f3c1c1ef3 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_review_fixes_test.go @@ -0,0 +1,274 @@ +package tgbot + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + + "github.com/mymmrac/telego" +) + +// A login success must not fold into the failure card of the same IP: the +// sequence is the alert. The live-card key therefore carries the outcome. +func TestLoginSuccessAndFailureGetSeparateCards(t *testing.T) { + tb := &Tgbot{} + fail := eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Source: "10.0.0.1", + Data: &eventbus.LoginEventData{IP: "10.0.0.1", Username: "root", Status: "fail"}, + } + ok := eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Source: "10.0.0.1", + Data: &eventbus.LoginEventData{IP: "10.0.0.1", Username: "root", Status: "success"}, + } + + if tb.eventNoticeKind(fail) == tb.eventNoticeKind(ok) { + t.Fatalf("a login success and a failure from the same IP share the key %q, so the "+ + "failure card is overwritten", tb.eventNoticeKind(fail)) + } + // Anything that is not a login keeps the old behaviour: one card per source. + out := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "proxy-a"} + other := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "proxy-b"} + if tb.eventNoticeKind(out) == tb.eventNoticeKind(other) { + t.Fatal("two different outbound sources share one card") + } +} + +// The scheduled report posts its own message, so a cron landing on the +// add-client wizard cannot replace the draft the admin is editing. +func TestScheduledDepletionReportDoesNotEditTheLiveScreen(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + // A wizard the admin has open: the tracked screen of this chat. + tb.renderScreen(555, tb.newScreen("wizard", "draft in progress", tb.backRow())) + + tb.sendReport(555, "🚨 Exhausted Clients count:\r\n🛑 Disabled: 0\r\n🔜 Deplete Soon: 0") + + for _, c := range calls() { + if c.Method == "editMessageText" || c.Method == "editMessageMedia" { + t.Fatalf("the scheduled report edited the admin's live screen (%s)", c.Method) + } + } + tracked, ok := tb.screens().get(555) + if !ok || tracked.kind != "wizard" { + t.Fatalf("the wizard screen was replaced (tracked=%v ok=%v)", tracked, ok) + } +} + +// Every message a report is split into carries its own dismiss button; the old +// behaviour left all but the last one undismissable. +func TestEveryReportMessageCanBeDismissed(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + + tb.sendReport(777, reportTestBody()) + + sent := 0 + for _, c := range calls() { + if c.Method != "sendMessage" { + continue + } + sent++ + markup, _ := json.Marshal(c.Payload["reply_markup"]) + if !strings.Contains(string(markup), "hide") { + t.Fatalf("report message %d offers no dismiss button: %s", sent, markup) + } + } + if sent < 2 { + t.Fatalf("the report went out as %d message(s); the test needs a split body", sent) + } +} + +// One report per run replaces the previous one instead of stacking a new copy +// every day. +func TestNewReportReplacesThePreviousOne(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + reportPrevSet(999, 0) + t.Cleanup(func() { reportPrevSet(999, 0) }) + + tb.sendReport(999, "first report") + firstID, ok := reportPrevGet(999) + if !ok || firstID == 0 { + t.Fatalf("the first report was not tracked (id=%d ok=%v)", firstID, ok) + } + + tb.sendReport(999, "second report") + + deleted := false + for _, c := range calls() { + if c.Method != "deleteMessage" { + continue + } + if id, ok := c.Payload["message_id"].(float64); ok && int(id) == firstID { + deleted = true + } + } + if !deleted { + t.Fatalf("the previous report (message %d) was not removed", firstID) + } + if prev, _ := reportPrevGet(999); prev == firstID { + t.Fatal("the tracked report id still points at the deleted message") + } +} + +// A client's daily alert about its own traffic is routine too: it must carry the +// same dismiss the rest of the bot uses, not arrive as an unclearable message. +func TestClientTrafficAlertIsDismissable(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + + tb.sendNotice(4242, "🚨 Your traffic is running out") + + for _, c := range calls() { + if c.Method != "sendMessage" { + continue + } + markup, _ := json.Marshal(c.Payload["reply_markup"]) + if !strings.Contains(string(markup), "hide") { + t.Fatalf("the client alert carries no dismiss button: %s", markup) + } + return + } + t.Fatal("no alert message was sent") +} + +// reportTestBody is long enough that pageMessage splits it into several messages. +func reportTestBody() string { + return strings.Repeat("📈 line of the scheduled report\r\n", 260) +} + +// The server screen carries the host's addresses and versions, so /status must +// not hand it to a client-level account. +func TestStatusCommandGivesAClientLivenessOnly(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + setTestAdmins(t, 1001) + + tb.answerCommand(&telego.Message{ + Chat: telego.Chat{ID: 2002, Type: "private"}, + From: &telego.User{ID: 2002}, + Text: "/status", + }, 2002, false) + + if methods := methods(calls()); len(methods) == 0 { + t.Fatal("a client got no answer to /status") + } + for _, c := range calls() { + text := c.text() + if strings.Contains(text, "Xray") || strings.Contains(text, "Uptime") { + t.Fatalf("a client received the server screen: %q", text) + } + } +} + +// The inline browser listed every inbound to any client-level account, which +// leaked remarks, ports and client counts. +func TestInlineBrowserRefusesInboundsForAClient(t *testing.T) { + tb := &Tgbot{} + for _, action := range []string{"", "inb", "add"} { + if got := tb.inlineResults(action, "", "", false); len(got) != 0 { + t.Fatalf("client-level inline scope %q returned %d results", action, len(got)) + } + } +} + +// Past ~80 clients the per-client reset report exceeded Telegram's message limit +// whole, so the admin saw no outcome at all. +func TestResetAllReportIsPaged(t *testing.T) { + tb, calls := newScreenTgbot(t, false) + + // Roughly what 120 clients produce. + body := strings.Repeat("✅ Traffic reset for client@example.com\r\n\r\n", 120) + tb.sendPagedNotice(31337, body) + + sent := 0 + for _, c := range calls() { + if c.Method != "sendMessage" { + continue + } + sent++ + if len(c.text()) > telegramPageLimit { + t.Fatalf("message %d is %d bytes, over the %d limit", sent, len(c.text()), telegramPageLimit) + } + } + if sent < 2 { + t.Fatalf("the report went out as %d message(s); it must be split", sent) + } +} + +// A single line longer than a caption used to become a page over the limit, and +// Telegram rejected it whole: the screen never appeared. +func TestOverlongLineIsSplitInsteadOfRejected(t *testing.T) { + long := "vless://" + strings.Repeat("a", 4000) + for i, page := range splitMessageLines(long, botCaptionLimit) { + if len(page) > botCaptionLimit { + t.Fatalf("page %d is %d bytes, over the %d caption limit", i, len(page), botCaptionLimit) + } + } + if joined := strings.Join(splitMessageLines(long, botCaptionLimit), ""); joined != long { + t.Fatal("splitting an over-long line lost or changed bytes") + } +} + +// /usage with no argument is how a client asks about its own traffic; the rows +// were built from email[0] and panicked on the empty slice. +func TestUsageWithoutAnArgumentDoesNotPanic(t *testing.T) { + initReportDB(t) + tb, _ := newScreenTgbot(t, false) + seedClientForTgUser(t, 3131, "usage-client@example.com") + + defer func() { + if r := recover(); r != nil { + t.Fatalf("getClientUsage panicked: %v", r) + } + }() + tb.getClientUsage(3131, 3131) + + if sc, ok := tb.screens().get(3131); !ok || sc.msgID == 0 { + t.Fatalf("the usage path never rendered a screen (ok=%v): the test does not exercise the row-building line", ok) + } +} + +// The usage card drew the admin keyboard for every caller, offering a client reset-traffic, IP-limit and +// enable buttons that act on an account. +func TestUsageGivesAClientNoAdminButtons(t *testing.T) { + initReportDB(t) + tb, _ := newScreenTgbot(t, false) + seedClientForTgUser(t, 4141, "usage-self@example.com") + + tb.getClientUsage(4141, 4141) + + sc, ok := tb.screens().get(4141) + if !ok { + t.Fatal("no screen was tracked") + } + markup, _ := json.Marshal(sc.markup) + for _, action := range []string{ + "reset_traffic", "limit_traffic", "ip_limit", "ip_log", + "toggle_enable", "tg_user", "reset_exp", "client_invite_link", + } { + if strings.Contains(string(markup), action) { + t.Fatalf("a client's usage card offered the admin control %q: %s", action, markup) + } + } +} + +// A non-admin email argument must not open an admin card either: /usage +// for a client resolves to its own card, never to the named one. +func TestUsageEmailArgumentDoesNotLeakAnotherClient(t *testing.T) { + initReportDB(t) + tb, _ := newScreenTgbot(t, false) + seedClientForTgUser(t, 5151, "mine@example.com") + seedClientForTgUser(t, 9999, "other@example.com") + + tb.getClientUsage(5151, 5151, "other@example.com") + + sc, ok := tb.screens().get(5151) + if !ok { + t.Fatal("no screen was tracked") + } + body := sc.body + sc.text + markup, _ := json.Marshal(sc.markup) + if strings.Contains(body, "other@example.com") || strings.Contains(string(markup), "other@example.com") { + t.Fatalf("a client's /usage leaked another client:\n%s\n%s", body, markup) + } +} diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index eda8bb934..107fdb896 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -2,7 +2,6 @@ package tgbot import ( "context" - "fmt" "html" "slices" "strconv" @@ -34,7 +33,7 @@ func runBotHandler(fn func()) { } // chooseInboundClient fetches the inbound once and reuses the row: the inline -// keyboard outlives the inbound, so a stale tap must answer an error, not panic. +// keyboard outlives it, so a stale tap must give an error, not a panic. func (t *Tgbot) chooseInboundClient(callbackQuery *telego.CallbackQuery, chatId int64, inboundID int, action string) { inbound, err := t.inboundService.GetInbound(inboundID) if err != nil { @@ -42,19 +41,28 @@ func (t *Tgbot) chooseInboundClient(callbackQuery *telego.CallbackQuery, chatId t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed")) return } - clientsKB, err := t.getInboundClientsFor(inbound, action) - if err != nil { - t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + clients, err := t.inboundService.GetClients(inbound) + if err != nil || len(clients) == 0 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getClientsFailed")) return } - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) + rows := [][]telego.InlineKeyboardButton{} + for _, client := range clients { + rows = append(rows, tu.InlineKeyboardRow( + tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))) + } + rows = append(rows, t.backRow()) + t.answerSilent(callbackQuery.ID) + t.renderScreen(chatId, t.newScreen("clients", + t.inboundSummaryText(inbound)+"\n"+t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), rows...)) } -// OnReceive starts the message receiving loop for the Telegram bot. +// OnReceive starts the message receiving loop for the Telegram liveBot(). 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 { @@ -72,18 +80,34 @@ func (t *Tgbot) OnReceive() { tgBotMutex.Unlock() // Get updates channel using the context with shorter timeout for better error recovery - updates, _ := bot.UpdatesViaLongPolling(ctx, ¶ms) + // One bot instance for the loop and the handler it feeds: reading the global twice could diverge them. + pollBot := liveBot() + updates, _ := pollBot.UpdatesViaLongPolling(ctx, ¶ms) go func() { defer botWG.Done() - h, _ := th.NewBotHandler(bot, updates) + h, _ := th.NewBotHandler(pollBot, updates) tgBotMutex.Lock() botHandler = h tgBotMutex.Unlock() + // The menu marks these as returning to a root screen, so a screen that + // carries one already offers a way out. + registerTerminalCallbacks(cbHome, cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance) + + refuseGroups(h, t) + + // A picked inline item is the user's own message, caught before the wizard + // and broadcast handlers; the predicate must not match every ":" message. + h.HandleMessage(func(ctx *th.Context, message telego.Message) error { + defer recoverBotPanic() + t.handleListMarker(&message) + return nil + }, messageIsListMarker, th.AnyMessageWithFrom()) + h.HandleMessage(func(ctx *th.Context, message telego.Message) error { defer recoverBotPanic() userStateMgr.clear(messageActor(message)) - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove()) + t.clearScreen(message) return nil }, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard"))) @@ -99,10 +123,23 @@ func (t *Tgbot) OnReceive() { if isAdmin, ok := t.gateCommand(&message); ok { t.answerCommand(&message, message.Chat.ID, isAdmin) } + // The command is litter next to the screen it opens: the bot's answer + // is worth keeping. Commands for another bot never reach here. + t.deleteIncoming(&message) }) return nil }, th.AnyCommand()) + // Inline mode is the only list browser: no middleware is registered on it, + // since a query fires on every keystroke and would be swallowed. + h.HandleInlineQuery(func(ctx *th.Context, query telego.InlineQuery) error { + defer recoverBotPanic() + go runBotHandler(func() { + t.handleInlineQuery(&query) + }) + return nil + }) + h.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error { // Use goroutine with worker pool for concurrent callback processing go runBotHandler(func() { @@ -130,7 +167,7 @@ func (t *Tgbot) OnReceive() { switch userState { case "awaiting_email": if draft.email == strings.TrimSpace(message.Text) { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + t.deleteIncoming(&message) userStateMgr.clear(actor) return nil } @@ -138,50 +175,40 @@ func (t *Tgbot) OnReceive() { draft.email = strings.TrimSpace(message.Text) if t.isSingleWord(draft.email) { userStateMgr.set(actor, "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) + t.deleteIncoming(&message) + t.wizardInvalidInput(message.Chat.ID, draft) } else { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove()) + t.deleteIncoming(&message) userStateMgr.clear(actor) t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft)) } case "awaiting_comment": if draft.comment == strings.TrimSpace(message.Text) { - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + t.deleteIncoming(&message) userStateMgr.clear(actor) return nil } draft.comment = strings.TrimSpace(message.Text) - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove()) + t.deleteIncoming(&message) userStateMgr.clear(actor) t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft)) case "awaiting_tg_id": input := strings.TrimSpace(message.Text) if input == "" || input == "-" || strings.EqualFold(input, "none") { draft.tgID = "" - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + t.deleteIncoming(&message) userStateMgr.clear(actor) t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft)) 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) + t.deleteIncoming(&message) + t.wizardInvalidInput(message.Chat.ID, draft) return nil } draft.tgID = input - t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove()) + t.deleteIncoming(&message) userStateMgr.clear(actor) t.addClient(message.Chat.ID, draft, t.BuildClientDraftMessage(draft)) } @@ -194,16 +221,14 @@ func (t *Tgbot) OnReceive() { if needRestart { t.xrayService.SetToNeedRestart() } - output := "" + output := t.I18nBot("tgbot.messages.userSaved") if err != nil { - output += t.I18nBot("tgbot.messages.selectUserFailed") - } else { - output += t.I18nBot("tgbot.messages.userSaved") + output = t.I18nBot("tgbot.messages.selectUserFailed") } - t.SendMsgToTgbot(message.Chat.ID, output, tu.ReplyKeyboardRemove()) + t.sendNoticeNoKeyboard(message.Chat.ID, output) } } else { - t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.noResult"), tu.ReplyKeyboardRemove()) + t.sendNoticeNoKeyboard(message.Chat.ID, t.I18nBot("tgbot.noResult")) } } } @@ -216,7 +241,9 @@ func (t *Tgbot) OnReceive() { // answerCommand processes incoming command messages from Telegram users. func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin bool) { - msg, onlyMessage := "", false + // onlyMessage is deliberately unused: every command path either renders a + // screen or sends a notice, both of which carry their own keyboard. + msg := "" command, _, commandArgs := tu.ParseCommand(message.Text) @@ -233,47 +260,49 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo case "start": if len(commandArgs) > 0 { if !isAdmin && !t.allowInviteAttempt(message.From) { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteRateLimited")) + t.sendNotice(chatId, t.I18nBot("tgbot.messages.inviteRateLimited")) return } t.claimInvite(chatId, message.From.ID, commandArgs[0]) } // A stranger learns only its ChatID, which is what an admin needs to bind it. if !isAdmin && t.levelOf(message.From.ID) == levelStranger { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(message.From.ID, 10))) + t.sendNotice(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(message.From.ID, 10))) return } - 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") + t.clearBroadcastPrompt(messageActor(*message)) + t.screenHome(chatId, message.From.ID) + return case "status": - onlyMessage = true - msg += t.I18nBot("tgbot.commands.status") + // The full server screen carries the host addresses and versions, so it + // stays admin-only; a client asking for status gets liveness alone. + if isAdmin { + t.screenServer(chatId) + return + } + t.sendNotice(chatId, t.I18nBot("tgbot.commands.statusOk")) + return case "id": - onlyMessage = true - msg += t.I18nBot("tgbot.commands.getID", "ID=="+strconv.FormatInt(message.From.ID, 10)) + t.sendNotice(chatId, t.I18nBot("tgbot.commands.getID", "ID=="+strconv.FormatInt(message.From.ID, 10))) + return case "usage": - onlyMessage = true if len(commandArgs) > 0 { if isAdmin { - t.searchClient(chatId, commandArgs[0]) + t.searchClientScreen(chatId, commandArgs[0]) } else { t.getClientUsage(chatId, message.From.ID, commandArgs[0]) } } else { - msg += t.I18nBot("tgbot.commands.usage") + t.getClientUsage(chatId, message.From.ID) } + return 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() { @@ -294,33 +323,23 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo 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() + t.renderScreen(chatId, t.resetAllConfirm()) + return } + handleUnknownCommand() case "broadcast": - onlyMessage = true if isAdmin { t.startBroadcast(messageActor(*message)) - } else { - handleUnknownCommand() + return } + handleUnknownCommand() default: handleUnknownCommand() } if msg != "" { - t.sendResponse(chatId, msg, onlyMessage, isAdmin) + t.sendNotice(chatId, msg) } } @@ -329,10 +348,10 @@ func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool { } func botUsername() string { - if bot == nil { + if liveBot() == nil { return "" } - return bot.Username() + return liveBot().Username() } func isCommandForBot(text string, username string) bool { @@ -361,7 +380,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool // A button older than the 20-minute hash window is the common case // here; the answer clears it, the message outlives a failed send. t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.noQuery")) - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noQuery")) + t.sendNotice(chatId, t.I18nBot("tgbot.noQuery")) return } dataArray := strings.Split(decodedQuery, " ") @@ -393,35 +412,35 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } t.chooseInboundClient(callbackQuery, chatId, inboundIdInt, "client_qr_links") case "client_sub_links": - t.sendClientSubLinks(chatId, email) + t.screenClientLinks(chatId, email) return case "client_individual_links": - t.sendClientIndividualLinks(chatId, email) + t.screenIndividualLinks(chatId, email) return case "client_qr_links": - t.sendClientQRLinks(chatId, email) + t.screenClientQR(chatId, email) return case "client_get_usage": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) - t.searchClient(chatId, email) + t.searchClientScreen(chatId, email) case "client_refresh": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.clientRefreshSuccess", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "client_cancel": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "ips_refresh": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.IpRefreshSuccess", "Email=="+email)) - t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) + t.screenClientIps(chatId, email) case "ips_cancel": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) - t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) + t.screenClientIps(chatId, email) case "tgid_refresh": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.TGIdRefreshSuccess", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) + t.screenClientTG(chatId, email, false) case "tgid_cancel": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) + t.screenClientTG(chatId, email, false) case "reset_traffic": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -431,12 +450,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic_c "+email)), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.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()) + t.searchClientScreen(chatId, email) } else { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) } @@ -470,7 +489,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 200")), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) case "limit_traffic_c": if len(dataArray) == 3 { limitTraffic, err := strconv.Atoi(dataArray[2]) @@ -481,13 +500,13 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } if err == nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.setTrafficLimitSuccess", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) return } } } t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "limit_traffic_in": if len(dataArray) >= 3 { oldInputNumber, err := strconv.Atoi(dataArray[2]) @@ -508,12 +527,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "limit_traffic", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) return } } t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "add_client_limit_traffic_c": limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64) draft.totalGB = limitTraffic * 1024 * 1024 * 1024 @@ -542,7 +561,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_limit_traffic", cancelData: "add_client_default_traffic_exp", confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) return } } @@ -572,7 +591,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool 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) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) case "reset_exp_c": if len(dataArray) == 3 { days, err := strconv.ParseInt(dataArray[2], 10, 64) @@ -582,13 +601,11 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool traffic, err := t.inboundService.GetClientTrafficByEmail(email) if err != nil { logger.Warning(err) - msg := t.I18nBot("tgbot.wentWrong") - t.SendMsgToTgbot(chatId, msg) + t.sendNotice(chatId, t.I18nBot("tgbot.wentWrong")) return } if traffic == nil { - msg := t.I18nBot("tgbot.noResult") - t.SendMsgToTgbot(chatId, msg) + t.sendNotice(chatId, t.I18nBot("tgbot.noResult")) return } @@ -609,13 +626,13 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } if err == nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.expireResetSuccess", "Email=="+email)) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) return } } } t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "reset_exp_in": if len(dataArray) >= 3 { oldInputNumber, err := strconv.Atoi(dataArray[2]) @@ -636,12 +653,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "reset_exp", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) return } } t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "add_client_reset_exp_c": // The wizard's presets and its custom keypad land in this one case, so a // second tap replaces the term it set; 0 is the Unlimited button. @@ -673,7 +690,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_reset_exp", cancelData: "add_client_default_traffic_exp", confirmLabelKey: "tgbot.buttons.confirmNumberAdd"}, inputNumber) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) return } } @@ -705,7 +722,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 10")), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) case "ip_limit_c": if len(dataArray) == 3 { count, err := strconv.Atoi(dataArray[2]) @@ -716,13 +733,13 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } 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()) + t.searchClientScreen(chatId, email) return } } } t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "ip_limit_in": if len(dataArray) >= 3 { oldInputNumber, err := strconv.Atoi(dataArray[2]) @@ -743,12 +760,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "ip_limit", dataArgs: email + " ", cancelData: "client_cancel " + email, confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) return } } t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) case "add_client_ip_limit_c": if len(dataArray) == 2 { count, _ := strconv.Atoi(dataArray[1]) @@ -780,7 +797,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } } inlineKeyboard := t.numericKeypad(numericKeypadSpec{dataBase: "add_client_ip_limit", cancelData: "add_client_default_ip_limit", confirmLabelKey: "tgbot.buttons.confirmNumber"}, inputNumber) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) return } } @@ -793,24 +810,30 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmClearIps")).WithCallbackData(t.encodeQuery("clear_ips_c "+email)), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.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()) + t.screenClientIps(chatId, email) } else { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.screenClientIps(chatId, email) } case "ip_log": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getIpLog", "Email=="+email)) - t.searchClientIps(chatId, email) + t.screenClientIps(chatId, email) + case "tgid_pick": + userStateMgr.set(actor, "awaiting_tg_pick") + t.answerSilent(callbackQuery.ID) + t.screenClientTG(chatId, email, false) + t.sendTGPicker(chatId, t.clientTrafficID(email)) case "tg_user": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getUserInfo", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email) + t.screenClientTG(chatId, email, false) case "client_invite_link": t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.inviteLink")) - t.sendInviteLink(chatId, email) + t.screenInviteLink(chatId, email) case "tgid_remove": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -820,7 +843,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmRemoveTGUser")).WithCallbackData(t.encodeQuery("tgid_remove_c "+email)), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) case "tgid_remove_c": traffic, err := t.inboundService.GetClientTrafficByEmail(email) if err != nil || traffic == nil { @@ -833,9 +856,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } if err == nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.removedTGUserSuccess", "Email=="+email)) - t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) + t.screenClientTG(chatId, email, false) } else { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.screenClientTG(chatId, email, false) } case "toggle_enable": inlineKeyboard := tu.InlineKeyboard( @@ -846,7 +870,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmToggle")).WithCallbackData(t.encodeQuery("toggle_enable_c "+email)), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderPickerOnClient(chatId, email, inlineKeyboard.InlineKeyboard) case "toggle_enable_c": enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email) if needRestart { @@ -858,45 +882,26 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } else { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.disableSuccess", "Email=="+email)) } - t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + t.searchClientScreen(chatId, email) } else { t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.searchClientScreen(chatId, email) } case "get_clients": - inboundId := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundId) + inboundIdInt, err := strconv.Atoi(dataArray[1]) 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) + t.answerSilent(callbackQuery.ID) + t.screenInboundClients(chatId, inboundIdInt) case "add_client_to": - draft.email = t.randomLowerAndNum(8) - draft.limitIP = 0 - draft.totalGB = 0 - draft.expiryTime = 0 - draft.enable = true - draft.tgID = "" - draft.subID = t.randomLowerAndNum(16) - draft.comment = "" - draft.reset = 0 - - inboundId := dataArray[1] - inboundIdInt, err := strconv.Atoi(inboundId) + inboundIdInt, err := strconv.Atoi(dataArray[1]) if err != nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return } + t.resetDraft(draft) draft.receiverInboundID = inboundIdInt draft.receiverInboundIDs = []int{inboundIdInt} t.addClient(callbackQuery.Message.GetChat().ID, draft, t.BuildClientDraftMessage(draft)) @@ -924,7 +929,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return } - t.editMessageCallbackTgBot(callbackQuery.Message.GetChat().ID, callbackQuery.Message.GetMessageID(), picker) + t.renderDraftPicker(callbackQuery.Message.GetChat().ID, draft, picker.InlineKeyboard) default: // An unknown action with arguments is still a tap, and an // unanswered tap spins until Telegram times it out. @@ -935,37 +940,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool switch callbackQuery.Data { case "broadcast_cancel": t.cancelBroadcast(actor, callbackQuery.Message.GetMessageID(), callbackQuery.ID) + // The broadcast step owns its own message; nothing else runs. return 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) + t.screenInbounds(chatId, callbackQuery.From.ID) + case "admin_client_sub_links", "admin_client_individual_links", "admin_client_qr_links": + t.browseInboundsScreen(chatId, callbackQuery.From.ID) } } } @@ -982,158 +962,85 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } 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 "home": + t.screenHome(chatId, callbackQuery.From.ID) + case cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance: + t.screenCategory(chatId, strings.TrimPrefix(callbackQuery.Data, "cat:")) + case "act": + // The old "Actions" screen folded into the Maintenance category. + t.screenCategory(chatId, "maintenance") + case "hide": + t.hideMessage(callbackQuery) + case "pg:next": + t.turnPage(callbackQuery, 1) + case "pg:prev": + t.turnPage(callbackQuery, -1) + case "pg:none": + t.answerSilent(callbackQuery.ID) + case "srv": + t.answerSilent(callbackQuery.ID) + t.screenServer(chatId) + case "inb", "inbounds", "get_inbounds": + t.screenInbounds(chatId, callbackQuery.From.ID) + case "cli": + t.answerSilent(callbackQuery.ID) + t.screenAllClients(chatId) + case "onl", "onlines", "onlines_refresh": + t.answerSilent(callbackQuery.ID) + t.screenOnlines(chatId) + case "dep", "deplete_soon": + t.screenDeplete(chatId) + case "rep", "get_sorted_traffic_usage_report": + t.screenReport(chatId) + case "bkp", "get_backup": + t.answerSilent(callbackQuery.ID) + t.sendBackupScreen(chatId) + case "ban", "get_banlogs": + t.answerSilent(callbackQuery.ID) + t.screenBanLogs(chatId) + case "inline_help": + t.answerSilent(callbackQuery.ID) + t.screenInlineHelp(chatId) + case "inline_recheck": + t.refreshInlineCapability() + if t.inlineSupported() { + t.screenInbounds(chatId, callbackQuery.From.ID) + } else { + t.screenInlineHelp(chatId) + } + case "cmd", "commands", "client_commands": + t.screenCommands(chatId, isAdmin) + case "addc", "add_client": + t.screenAddClientStart(chatId, callbackQuery.From.ID) + case cbBroadcast: + // The broadcast flow already existed behind /broadcast; the button is + // the discoverable way in. It owns its own messages, so no screen here. + t.answerSilent(callbackQuery.ID) + t.startBroadcast(actor) + case "xray_restart": + t.restartXrayFromScreen(callbackQuery) + case "rst_all", "reset_all_traffics": + t.renderScreen(chatId, t.resetAllConfirm()) 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": - draft.email = t.randomLowerAndNum(8) - draft.limitIP = 0 - draft.totalGB = 0 - draft.expiryTime = 0 - draft.enable = true - draft.tgID = "" - draft.subID = t.randomLowerAndNum(16) - draft.comment = "" - draft.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) + t.answerSilent(callbackQuery.ID) + t.getClientUsage(chatId, callbackQuery.From.ID) + case "client_sub_links", "client_individual_links", "client_qr_links": + // A client tapped its own link button: its identity already says which + // subscription, so it opens directly. A second one still needs a choice. + t.openOwnClientScreen(chatId, callbackQuery.From.ID, callbackQuery.Data) case "add_client_ch_default_email": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) userStateMgr.set(actor, "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=="+html.EscapeString(draft.email)) - t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) + t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(draft.email))) case "add_client_ch_default_comment": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) userStateMgr.set(actor, "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=="+html.EscapeString(draft.comment)) - t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) + t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(draft.comment))) case "add_client_ch_default_tg_id": - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) userStateMgr.set(actor, "awaiting_tg_id") - cancel_btn_markup := tu.InlineKeyboard( - tu.InlineKeyboardRow( - tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), - ), - ) current := draft.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", html.EscapeString(current)), cancel_btn_markup) + t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.tgid_prompt", "ClientTGID=="+html.EscapeString(current))) case "add_client_ch_default_traffic": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -1164,7 +1071,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 200")), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderDraftPicker(chatId, draft, inlineKeyboard.InlineKeyboard) case "add_client_ch_default_exp": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -1193,7 +1100,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton("12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 365")), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderDraftPicker(chatId, draft, inlineKeyboard.InlineKeyboard) case "add_client_ch_default_ip_limit": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -1222,34 +1129,24 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 10")), ), ) - t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + t.renderDraftPicker(chatId, draft, inlineKeyboard.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(actor) - t.addClient(chatId, draft, t.BuildClientDraftMessage(draft)) + t.addClient(chatId, draft, t.BuildClientDraftMessage(draft), callbackQuery.Message.GetMessageID()) case "add_client_cancel": userStateMgr.clear(actor) addClientDrafts.reset(actor) - 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(draft) - t.addClient(chatId, draft, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+draft.email)) - case "add_client_default_ip_limit": - messageId := callbackQuery.Message.GetMessageID() - message_text := t.BuildClientDraftMessage(draft) - t.addClient(chatId, draft, message_text, messageId) - t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+draft.email)) + t.renderScreen(chatId, t.newScreen("main", t.I18nBot("tgbot.messages.cancel"), t.homeRows()...)) + case "add_client_default_traffic_exp", "add_client_default_ip_limit": + // The picker was cancelled: the draft comes back unchanged. + t.addClient(chatId, draft, t.BuildClientDraftMessage(draft), callbackQuery.Message.GetMessageID()) case "add_client_attach_more": picker, err := t.getInboundsAttachPicker(draft) if err != nil { t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) return } - t.SendMsgToTgbot(chatId, "Pick inbound(s) to attach:", picker) + t.renderDraftPicker(chatId, draft, picker.InlineKeyboard) case "add_client_attach_done": if draft.receiverInboundID == 0 && len(draft.receiverInboundIDs) > 0 { draft.receiverInboundID = draft.receiverInboundIDs[0] @@ -1258,58 +1155,32 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed")) return } - message_text := t.BuildClientDraftMessage(draft) - t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) - t.addClient(chatId, draft, message_text) - case "add_client_submit_disable": - draft.enable = false - _, err := t.SubmitAddClient(draft) - 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, draft.email) - t.sendClientQRLinks(chatId, draft.email) - addClientDrafts.reset(actor) - } - case "add_client_submit_enable": - draft.enable = true - _, err := t.SubmitAddClient(draft) - 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, draft.email) - t.sendClientQRLinks(chatId, draft.email) - addClientDrafts.reset(actor) + t.addClient(chatId, draft, t.BuildClientDraftMessage(draft), callbackQuery.Message.GetMessageID()) + case "add_client_submit_disable", "add_client_submit_enable": + // Both submits land here and differ only in the enable flag; the data is + // short enough never to be hashed, so its raw form is the discriminator. + draft.enable = callbackQuery.Data == "add_client_submit_enable" + if _, err := t.SubmitAddClient(draft); err != nil { + t.sendNotice(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+err.Error())) + return } + email := draft.email + addClientDrafts.reset(actor) + // The new client's card is the screen: the links and the QR hang off it, + // so nobody has to go looking for the client they just created. + t.searchClientScreen(chatId, email) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) 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) + t.renderScreen(chatId, t.newScreen("main", t.I18nBot("tgbot.messages.cancel"), t.homeRows()...)) 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()) + t.sendNotice(chatId, t.I18nBot("tgbot.answers.errorOperation")) return } // One report per tap, not one message per client: a large panel would - // otherwise burst past Telegram's rate limit. SendMsgToTgbot pages it. + // otherwise burst past Telegram's rate limit. var report strings.Builder for _, email := range emails { if err := t.inboundService.ResetClientTrafficByEmail(email); err == nil { @@ -1321,47 +1192,12 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } report.WriteString(t.I18nBot("tgbot.messages.FinishProcess")) - // Escaped whole: one stray "<" in a remark or email otherwise makes - // Telegram reject the page it landed on, losing ~15 clients at once. - t.SendMsgToTgbot(chatId, html.EscapeString(report.String()), 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 - } - validEmails, missingEmails, err := t.inboundService.FilterAndSortClientEmails(emails) - if err != nil { - t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) - return - } - - // Batched for the same reason as the reset report above: one message - // per client hits Telegram's rate limit on a large panel. - var report strings.Builder - for _, email := range validEmails { - traffic, err := t.inboundService.GetClientTrafficByEmail(email) - if err != nil { - logger.Warning(err) - report.WriteString(t.I18nBot("tgbot.wentWrong")) - report.WriteString("\r\n\r\n") - continue - } - if traffic == nil { - report.WriteString(t.I18nBot("tgbot.noResult")) - report.WriteString("\r\n\r\n") - continue - } - report.WriteString(t.clientInfoMsg(traffic, false, false, false, false, true, false)) - report.WriteString("\r\n\r\n") - } - for _, email := range missingEmails { - fmt.Fprintf(&report, "📧 %s\r\n%s\r\n\r\n", email, t.I18nBot("tgbot.noResult")) - } - if report.Len() > 0 { - t.SendMsgToTgbot(chatId, html.EscapeString(report.String()), tu.ReplyKeyboardRemove()) - } + // Escaped whole (a stray "<" makes Telegram reject a page) and paged: + // past ~80 clients the report exceeds one message. + t.sendPagedNotice(chatId, html.EscapeString(report.String())) + // The confirmation has served its purpose: leaving it on screen left the + // only button being the one just pressed. + t.screenHome(chatId, callbackQuery.From.ID) default: action, email, ok := splitClientLinkCallback(callbackQuery.Data) if !ok { @@ -1378,11 +1214,11 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } switch action { case "client_sub_links": - t.sendClientSubLinks(chatId, email) + t.screenClientLinks(chatId, email) case "client_individual_links": - t.sendClientIndividualLinks(chatId, email) + t.screenIndividualLinks(chatId, email) case "client_qr_links": - t.sendClientQRLinks(chatId, email) + t.screenClientQR(chatId, email) } } } @@ -1396,7 +1232,9 @@ func checkAdmin(tgId int64) bool { // admin-only; the caller still has to prove the client is its own. func isClientSelfCallback(data string) bool { switch data { - case "client_traffic", "client_commands", "client_sub_links", + // "hide" is how any recipient puts a message away: it carries no target of its + // own (it is the message it sits on), so it is safe for anyone to reach. + case "home", "hide", "client_traffic", "client_commands", "client_sub_links", "client_individual_links", "client_qr_links": return true } @@ -1414,3 +1252,50 @@ func splitClientLinkCallback(data string) (action, email string, ok bool) { } return "", "", false } + +// refuseGroups keeps every update in a private chat: a group shares one screen and keyboard among its +// members, so admins and clients would overwrite each other. +func refuseGroups(h *th.BotHandler, t *Tgbot) { + h.Use(func(ctx *th.Context, update telego.Update) error { + if chatType := updateChatType(update); chatType != "" && chatType != telego.ChatTypePrivate { + t.refuseNonPrivateChat(update) + return nil + } + return ctx.Next(update) + }) +} + +// updateChatType reports a message or callback chat kind, "channel" for an inline query from a channel the +// sender is not in; an inline query's "sender" type counts as private. +func updateChatType(update telego.Update) string { + switch { + case update.Message != nil: + return update.Message.Chat.Type + case update.CallbackQuery != nil && update.CallbackQuery.Message != nil: + return update.CallbackQuery.Message.GetChat().Type + case update.InlineQuery != nil: + if update.InlineQuery.ChatType == "" || update.InlineQuery.ChatType == "sender" { + return telego.ChatTypePrivate + } + return update.InlineQuery.ChatType + } + return "" +} + +// refuseNonPrivateChat answers a group or channel that the bot refuses to serve. +func (t *Tgbot) refuseNonPrivateChat(update telego.Update) { + if update.InlineQuery != nil { + t.answerInline(update.InlineQuery.ID, nil, "") + return + } + chatID := int64(0) + if update.Message != nil { + chatID = update.Message.Chat.ID + } else if update.CallbackQuery != nil && update.CallbackQuery.Message != nil { + chatID = update.CallbackQuery.Message.GetChat().ID + t.answerSilent(update.CallbackQuery.ID) + } + if chatID != 0 { + t.sendNoticeNoKeyboard(chatID, t.I18nBot("tgbot.messages.privateOnly")) + } +} diff --git a/internal/web/service/tgbot/tgbot_send.go b/internal/web/service/tgbot/tgbot_send.go index c3f8211f4..e134f1c18 100644 --- a/internal/web/service/tgbot/tgbot_send.go +++ b/internal/web/service/tgbot/tgbot_send.go @@ -12,71 +12,6 @@ import ( 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 { @@ -107,8 +42,18 @@ func splitMessageLines(block string, limit int) []string { } lines := strings.Split(block, "\r\n") - pages := []string{lines[0]} - for _, line := range lines[1:] { + pages := []string{} + for _, line := range lines { + // A single link can exceed a caption on its own; Telegram counts bytes, + // so such a line is split rather than kept whole and rejected. + for len(line) > limit { + pages = append(pages, line[:limit]) + line = line[limit:] + } + if len(pages) == 0 { + pages = append(pages, line) + continue + } last := len(pages) - 1 if len(pages[last])+len("\r\n")+len(line) > limit { pages = append(pages, line) @@ -116,19 +61,24 @@ func splitMessageLines(block string, limit int) []string { } pages[last] += "\r\n" + line } + if len(pages) == 0 { + pages = append(pages, "") + } 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) { +// SendMsgToTgbot sends a message with optional reply markup and returns the id +// of the last one delivered, or 0; only the broadcast prompt needs it. +func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) int { if !t.IsRunning() { - return + return 0 } if msg == "" { logger.Info("[tgbot] message is empty!") - return + return 0 } + lastID := 0 allMessages := pageMessage(msg, telegramPageLimit) for n, message := range allMessages { @@ -147,10 +97,13 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R maxRetries := 3 for attempt := range maxRetries { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - _, err := bot.SendMessage(ctx, ¶ms) + sent, err := liveBot().SendMessage(ctx, ¶ms) cancel() if err == nil { + if sent != nil { + lastID = sent.MessageID + } break // Success } @@ -177,6 +130,7 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R time.Sleep(100 * time.Millisecond) } } + return lastID } // SendMsgToTgbotAdmins sends a message to all admin Telegram chats. @@ -199,7 +153,7 @@ func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) { CallbackQueryID: id, Text: message, } - if err := bot.AnswerCallbackQuery(context.Background(), ¶ms); err != nil { + if err := liveBot().AnswerCallbackQuery(context.Background(), ¶ms); err != nil { logger.Warning(err) } } @@ -211,7 +165,7 @@ func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyb MessageID: messageID, ReplyMarkup: inlineKeyboard, } - if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil { + if _, err := liveBot().EditMessageReplyMarkup(context.Background(), ¶ms); err != nil { if isTelegramNotModifiedError(err) { logger.Debug("Telegram reply markup unchanged, skipping edit") return @@ -231,7 +185,7 @@ func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlin if len(inlineKeyboard) > 0 { params.ReplyMarkup = inlineKeyboard[0] } - if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil { + if _, err := liveBot().EditMessageText(context.Background(), ¶ms); err != nil { if isTelegramNotModifiedError(err) { logger.Debug("Telegram message text unchanged, skipping edit") return @@ -251,50 +205,24 @@ func isTelegramNotModifiedError(err error) bool { strings.Contains(errStr, "No fields to modify") } -// 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 +// sendPagedNotice posts one notice per page, each dismissable. Telegram rejects +// a message over 4096 characters whole, so a per-client report has to be split. +func (t *Tgbot) sendPagedNotice(chatID int64, text string) { + for _, chunk := range pageMessage(text, telegramPageLimit) { + t.sendNoticeReturningID(chatID, chunk) } - - // Send the message - sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{ - ChatID: tu.ID(chatId), - MessageThreadID: adminTopic(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 { + if liveBot() == nil { return } params := telego.DeleteMessageParams{ ChatID: tu.ID(chatId), MessageID: messageID, } - if err := bot.DeleteMessage(context.Background(), ¶ms); err != nil { + if err := liveBot().DeleteMessage(context.Background(), ¶ms); err != nil { logger.Warning("Failed to delete message:", err) } else { logger.Info("Message deleted successfully") @@ -303,9 +231,7 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) { // TestConnection verifies the bot token is valid and the API is reachable. func (t *Tgbot) TestConnection() error { - tgBotMutex.Lock() - b := bot - tgBotMutex.Unlock() + b := liveBot() if b == nil { return fmt.Errorf("bot not initialized") } diff --git a/internal/web/service/tgbot/tgbot_send_test.go b/internal/web/service/tgbot/tgbot_send_test.go index ce338fa03..958ffccf6 100644 --- a/internal/web/service/tgbot/tgbot_send_test.go +++ b/internal/web/service/tgbot/tgbot_send_test.go @@ -53,8 +53,8 @@ func TestEditMessageTgBotSkipsNotModified(t *testing.T) { defer mock.Close() // Point the package-level bot at the mock. - origBot := bot - t.Cleanup(func() { bot = origBot }) + origBot := liveBot() + t.Cleanup(func() { setTestBot(origBot) }) var err error bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(mock.URL)) if err != nil { @@ -85,8 +85,8 @@ func TestEditMessageCallbackTgBotSkipsNotModified(t *testing.T) { })) defer mock.Close() - origBot := bot - t.Cleanup(func() { bot = origBot }) + origBot := liveBot() + t.Cleanup(func() { setTestBot(origBot) }) var err error bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(mock.URL)) if err != nil { diff --git a/internal/web/service/tgbot/tgbot_stale_button_test.go b/internal/web/service/tgbot/tgbot_stale_button_test.go index 90feddff4..da24a6a8e 100644 --- a/internal/web/service/tgbot/tgbot_stale_button_test.go +++ b/internal/web/service/tgbot/tgbot_stale_button_test.go @@ -40,20 +40,32 @@ func staleButtonServer(t *testing.T, responses map[string]any) (*httptest.Server } } +// setTestBot swaps the process-wide connection the way EnsureBot does: under the +// mutex, so the race detector sees a well-defined handover. +func setTestBot(b *telego.Bot) { + botMu.Lock() + bot = b + botMu.Unlock() +} + func swapTestBot(t *testing.T, url string) { t.Helper() - origBot := bot + origBot := liveBot() origPool := messageWorkerPool t.Cleanup(func() { - bot = origBot + setTestBot(origBot) messageWorkerPool = origPool }) - var err error - bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(url)) + newBot, err := telego.NewBot(testBotToken, telego.WithAPIServer(url)) if err != nil { t.Fatalf("NewBot: %v", err) } + setTestBot(newBot) messageWorkerPool = make(chan struct{}, 10) + // The screen store is process-wide state: a screen left by an earlier test + // would make this one edit an unrelated message instead of sending. + screens.reset() + resetScreenArt() } func newStaleButtonTgbot(t *testing.T) *Tgbot { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index fdcd6c815..4c00eec1e 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -85,7 +85,6 @@ "uploaded": "الرفع", "expiry": "تاريخ الانتهاء", "totalQuota": "الحصة الإجمالية", - "individualLinks": "روابط فردية", "active": "نشط", "inactive": "غير نشط", "unlimited": "غير محدود", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "IP سيرفر DoH", "subIncyResolveDnsIpDesc": "عنوان IP لسيرفر DoH للتهيئة.", "subIncyGroupRouting": "التوجيه", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "خصوصية المجموعات مُفعّلة", + "tgPrivacyOnDesc": "هذا البوت يرد في المحادثة الخاصة فقط، لذا يتجاهل المجموعات عن قصد. خصوصية المجموعات تحدد فقط ما إذا كان البوت يستطيع قراءة رسائل المجموعة، وهو لا يستخدم ذلك.", "subHostSortMode": "ترتيب عقد الاشتراك", "subHostSortInbound": "ترتيب الاتصالات الواردة (الافتراضي)", "subHostSortHosts": "ترتيب المضيفين", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ لوحة المفاتيح مغلقة!", "noResult": "❗ لا يوجد نتائج!", "noQuery": "❌ لم يتم العثور على الاستعلام! يرجى استخدام الأمر مرة أخرى!", "wentWrong": "❌ حدث خطأ ما!", @@ -2508,10 +2511,6 @@ "unknown": "❗ أمر مش معروف.", "pleaseChoose": "👇 من فضلك اختار:\r\n", "help": "🤖 أهلا بيك في البوت! البوت ده معمول عشان يديك بيانات معينة من البانل ويسمحلك بالتعديلات.", - "start": "👋 أهلا {{ .Firstname }}.\r\n", - "welcome": "🤖 أهلا بيك في بوت إدارة {{ .Hostname }}.\r\n", - "status": "✅ البوت شغال!", - "usage": "❗ من فضلك ادخل نص للتبحث عنه!", "getID": "🆔 الـ ID بتاعك: {{ .ID }}", "helpAdminCommands": "عشان تعيد تشغيل Xray Core:\r\n/restart\r\n\r\nعشان تدور على إيميل عميل:\r\n/usage [Email]\r\n\r\nعشان تدور على إدخالات (مع إحصائيات العملاء):\r\n/inbound [Remark]\r\n\r\nID شات Telegram:\r\n/id", "helpClientCommands": "عشان تدور على الإحصائيات، استخدم الأمر ده:\r\n\r\n/usage [Email]\r\n\r\nID شات Telegram:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ حصل خطأ في العملية.\r\n\r\nError: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core مش شغال.", "startDesc": "عرض القائمة الرئيسية", - "helpDesc": "مساعدة البوت", - "statusDesc": "التحقق من حالة البوت", - "idDesc": "عرض معرف Telegram الخاص بك", - "usageDesc": "عرض استهلاك العميل: /usage البريد", - "inboundDesc": "البحث في الواردات: /inbound الاسم (مشرف)", - "restartDesc": "إعادة تشغيل نواة Xray (مشرف)", - "broadcastDesc": "إرسال رسالة لجميع العملاء (مسؤول)", - "clearallDesc": "تصفير استهلاك جميع العملاء (مشرف)" + "statusOk": "✅ البوت يعمل!" }, "messages": { "cpuThreshold": "حمل المعالج {{ .Percent }}% عدى الحد المسموح ({{ .Threshold }}%)", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ رابط الدعوة هذا غير صالح أو تم استخدامه بالفعل.\r\n", "inviteRateLimited": "⏳ محاولات دعوة كثيرة جدًا. يرجى المحاولة لاحقًا.\r\n", "inviteRateLimitedAdmin": "⚠️ تم حظر محاولات الدعوة لـ {{ .User }} ({{ .ID }}): أكثر من {{ .Limit }} محاولات خلال ساعة. ربما يحاول أحدهم تخمين معرّفات الاشتراك.", - "inviteLink": "🔗 رابط الدعوة لـ {{ .Email }}:\r\n{{ .Link }}\r\n\r\nأول شخص يفتحه سيتم ربطه بهذا العميل.\r\n", "exhaustedCount": "🚨 عدد النفاذ لـ {{ .Type }}:\r\n", "onlinesCount": "🌐 العملاء الأونلاين: {{ .Count }}\r\n", "disabled": "🛑 معطل: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 اتحدّث في: {{ .Time }}\r\n\r\n", "yes": "✅ أيوه", "no": "❌ لا", - "received_email": "📧📥 الإيميل اتحدث.", - "received_comment": "💬📥 التعليق اتحدث.", "email_prompt": "📧 الإيميل الافتراضي: {{ .ClientEmail }}\n\nادخل الإيميل بتاعك.", "comment_prompt": "💬 التعليق الافتراضي: {{ .ClientComment }}\n\nادخل تعليقك.", + "tgid_prompt": "🆔 مستخدم TG الافتراضي: {{ .ClientTGID }}\n\nأرسل المعرّف الرقمي للمستخدم لربطه بهذا العميل، أو أرسل - / none لمسحه.", "cancel": "❌ العملية اتلغت! \n\nممكن تبدأ من /start في أي وقت. 🔄", "error_add_client": "⚠️ خطأ:\n\n {{ .error }}", - "using_default_value": "تمام، هشيل على القيمة الافتراضية. 😊", "incorrect_input": "المدخلات مش صحيحة.\nالكلمات لازم تكون متصلة من غير فراغات.\nمثال صحيح: aaaaaa\nمثال غلط: aaa aaa 🚫", "broadcastAskText": "📤 بث جماعي: أرسل لي الرسالة لتوصيلها إلى عملائك — نص أو صورة أو فيديو أو ملفًا أو ألبومًا كاملًا.", "broadcastAlreadyRunning": "❗ هناك بث جارٍ بالفعل، انتظر حتى ينتهي.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "العقدة {{ .Name }} غير متصلة", "eventNodeUp": "العقدة {{ .Name }} متصلة", "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}", - "memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%" + "memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 الخادم", + "category_clients": "👥 العملاء", + "category_traffic": "🚦 حركة المرور", + "category_maintenance": "⚙️ الصيانة", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 رموز QR:", + "qrLegendSubscription": "{{ .Code }}. اشتراك", + "qrLegendJSON": "{{ .Code }}. اشتراك JSON", + "qrLegendLink": "{{ .Code }}. رابط التطبيق {{ .App }}", + "backupFailed": "فشل قراءة قاعدة البيانات؛ لم يُرسل أي نسخة احتياطية.", + "privateOnly": "🤖 يعمل هذا البوت في المحادثة الخاصة فقط. يرجى فتحه في رسالة مباشرة." }, "buttons": { "closeKeyboard": "❌ اقفل الكيبورد", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ تأكيد مسح الـ IPs؟", "confirmRemoveTGUser": "✅ تأكيد حذف مستخدم Telegram؟", "confirmToggle": "✅ تأكيد تفعيل/تعطيل المستخدم؟", - "dbBackup": "احصل على نسخة DB", - "serverUsage": "استخدام السيرفر", - "getInbounds": "احصل على الإدخالات", - "depleteSoon": "هينتهي قريب", - "clientUsage": "استخدام العميل", - "onlines": "العملاء الأونلاين", - "commands": "الأوامر", + "dbBackup": "💾 احصل على نسخة DB", + "serverUsage": "📊 استخدام السيرفر", + "getInbounds": "🔌 احصل على الإدخالات", + "depleteSoon": "⚠️ هينتهي قريب", + "onlines": "🟢 العملاء الأونلاين", + "catServer": "🖥 الخادم", + "catClients": "👥 العملاء", + "catTraffic": "🚦 حركة المرور", + "catMaintenance": "⚙️ الصيانة", "refresh": "🔄 تجديد", "clearIPs": "❌ مسح الـ IPs", "removeTGUser": "❌ حذف مستخدم Telegram", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ تأكيد: {{ .Num }}", "confirmNumberAdd": "✅ تأكيد إضافة: {{ .Num }}", "limitTraffic": "🚧 حد الترافيك", - "getBanLogs": "احصل على سجلات الحظر", - "allClients": "كل العملاء", - "addClient": "إضافة عميل", + "getBanLogs": "📄 احصل على سجلات الحظر", + "allClients": "👥 كل العملاء", + "addClient": "➕ إضافة عميل", "submitDisable": "إرسال كمعطّل ☑️", "submitEnable": "إرسال كمفعّل ✅", "use_default": "🏷️ استخدام الإعدادات الافتراضية", "change_email": "⚙️📧 البريد", "change_comment": "⚙️💬 تعليق", - "ResetAllTraffics": "إعادة ضبط جميع الترافيك", - "SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب" + "ResetAllTraffics": "♻️ إعادة ضبط جميع الترافيك", + "SortedTrafficUsageReport": "📈 تقرير استخدام الترافيك المرتب", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 بث", + "attachInbound": "إرفاق مدخل ({{ .Count }})", + "attachDone": "✅ تم" }, "answers": { "broadcastStarted": "🚀 بدأ البث.", @@ -2682,6 +2703,36 @@ "chooseClient": "اختار عميل للإدخال {{ .Inbound }}", "chooseInbound": "اختار الإدخال", "qrCodeForClient": "رمز QR للعميل {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "منتهي", + "fromFirstUse": "من أول استخدام" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 71121a48a..5611e51b3 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -85,7 +85,6 @@ "uploaded": "Uploaded", "expiry": "Expiry", "totalQuota": "Total quota", - "individualLinks": "Individual links", "active": "Active", "inactive": "Inactive", "unlimited": "Unlimited", @@ -1950,6 +1949,11 @@ "subIncyResolveDnsIp": "DoH server IP", "subIncyResolveDnsIpDesc": "IP of the DoH server, used for bootstrap.", "subIncyGroupRouting": "Routing", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "Group Privacy is on", + "tgPrivacyOnDesc": "This bot answers in private chat only, so groups are ignored on purpose. Group Privacy only decides whether the bot may read group messages, which it does not use.", "subHostSortMode": "Subscription Node Ordering", "subHostSortInbound": "Inbound order (default)", "subHostSortHosts": "Hosts order", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Custom keyboard closed!", "noResult": "❗ No result!", "noQuery": "❌ Query not found! Please use the command again!", "wentWrong": "❌ Something went wrong!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Unknown command.", "pleaseChoose": "👇 Please choose:\r\n", "help": "🤖 Welcome to this bot! It's designed to offer specific data from the web panel and allows you to make modifications as needed.\r\n\r\n", - "start": "👋 Hello {{ .Firstname }}.\r\n", - "welcome": "🤖 Welcome to {{ .Hostname }} management bot.\r\n", - "status": "✅ Bot is OK!", - "usage": "❗ Please provide a text to search!", "getID": "🆔 Your ID: {{ .ID }}", "helpAdminCommands": "To restart Xray Core:\r\n/restart\r\n\r\nTo search for a client email:\r\n/usage [Email]\r\n\r\nTo search for inbounds (with client stats):\r\n/inbound [Remark]\r\n\r\nTelegram Chat ID:\r\n/id", "helpClientCommands": "To search for statistics, use the following command:\r\n\r\n/usage [Email]\r\n\r\nTelegram Chat ID:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Error in operation.\r\n\r\nError: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core is not running.", "startDesc": "Show the main menu", - "helpDesc": "Bot help", - "statusDesc": "Check bot status", - "idDesc": "Show your Telegram ID", - "usageDesc": "Show client usage: /usage email", - "inboundDesc": "Search inbounds: /inbound remark (admin)", - "restartDesc": "Restart Xray core (admin)", - "broadcastDesc": "Broadcast a message to all clients (admin)", - "clearallDesc": "Reset all clients' traffic (admin)" + "statusOk": "✅ Bot is OK!" }, "messages": { "cpuThreshold": "CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ This invite link is invalid or has already been used.\r\n", "inviteRateLimited": "⏳ Too many invite attempts. Please try again later.\r\n", "inviteRateLimitedAdmin": "⚠️ Invite attempts blocked for {{ .User }} ({{ .ID }}): more than {{ .Limit }} tries in an hour. Someone may be guessing Subscription IDs.", - "inviteLink": "🔗 Invite link for {{ .Email }}:\r\n{{ .Link }}\r\n\r\nThe first person to open it is linked to this client.\r\n", "exhaustedCount": "🚨 Exhausted {{ .Type }} count:\r\n", "onlinesCount": "🌐 Online Clients: {{ .Count }}\r\n", "disabled": "🛑 Disabled: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Refreshed On: {{ .Time }}\r\n\r\n", "yes": "✅ Yes", "no": "❌ No", - "received_email": "📧📥 Email updated.", - "received_comment": "💬📥 Comment updated.", "email_prompt": "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email.", "comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment.", + "tgid_prompt": "🆔 Default TG user: {{ .ClientTGID }}\n\nSend the numeric user id to attach to this client, or send - / none to clear.", "cancel": "❌ Process Canceled! \n\nYou can /start again anytime. 🔄", "error_add_client": "⚠️ Error:\n\n {{ .error }}", - "using_default_value": "Okay, I'll stick with the default value. 😊", "incorrect_input": "Your input is not valid.\nThe phrases should be continuous without spaces.\nCorrect example: aaaaaa\nIncorrect example: aaa aaa 🚫", "broadcastAskText": "📤 Broadcast: send me the message to deliver to your clients — text, photo, video, file or a whole album.", "broadcastAlreadyRunning": "❗ A broadcast is already running, please wait for it to finish.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "Node {{ .Name }} is DOWN", "eventNodeUp": "Node {{ .Name }} is UP", "eventLoginFallback": "Login failed from {{ .Source }}", - "memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%" + "memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Server", + "category_clients": "👥 Clients", + "category_traffic": "🚦 Traffic", + "category_maintenance": "⚙️ Maintenance", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 QR codes:", + "qrLegendSubscription": "{{ .Code }}. Subscription", + "qrLegendJSON": "{{ .Code }}. JSON subscription", + "qrLegendLink": "{{ .Code }}. App link {{ .App }}", + "backupFailed": "Could not read the database; no backup was sent.", + "privateOnly": "🤖 This bot works in private chat only. Please open it in a direct message." }, "buttons": { "closeKeyboard": "❌ Close Keyboard", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ Confirm Clear IPs?", "confirmRemoveTGUser": "✅ Confirm Remove Telegram User?", "confirmToggle": "✅ Confirm Enable/Disable User?", - "dbBackup": "Get DB Backup", - "serverUsage": "Server Usage", - "getInbounds": "Get Inbounds", - "depleteSoon": "Deplete Soon", - "clientUsage": "Get Usage", - "onlines": "Online Clients", - "commands": "Commands", + "dbBackup": "💾 Get DB Backup", + "serverUsage": "📊 Server Usage", + "getInbounds": "🔌 Get Inbounds", + "depleteSoon": "⚠️ Deplete Soon", + "onlines": "🟢 Online Clients", + "catServer": "🖥 Server", + "catClients": "👥 Clients", + "catTraffic": "🚦 Traffic", + "catMaintenance": "⚙️ Maintenance", "refresh": "🔄 Refresh", "clearIPs": "❌ Clear IPs", "removeTGUser": "❌ Remove Telegram User", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Confirm: {{ .Num }}", "confirmNumberAdd": "✅ Confirm adding: {{ .Num }}", "limitTraffic": "🚧 Traffic Limit", - "getBanLogs": "Get Ban Logs", - "allClients": "All Clients", - "addClient": "Add Client", + "getBanLogs": "📄 Get Ban Logs", + "allClients": "👥 All Clients", + "addClient": "➕ Add Client", "submitDisable": "Submit As Disable ☑️", "submitEnable": "Submit As Enable ✅", "use_default": "🏷️ Use default", "change_email": "⚙️📧 Email", "change_comment": "⚙️💬 Comment", - "ResetAllTraffics": "Reset All Traffic", - "SortedTrafficUsageReport": "Sorted Traffic Usage Report" + "ResetAllTraffics": "♻️ Reset All Traffic", + "SortedTrafficUsageReport": "📈 Sorted Traffic Usage Report", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Broadcast", + "attachInbound": "Attach inbound ({{ .Count }})", + "attachDone": "✅ Done" }, "answers": { "broadcastStarted": "🚀 Broadcast started.", @@ -2682,6 +2703,36 @@ "chooseClient": "Choose a Client for Inbound {{ .Inbound }}", "chooseInbound": "Choose an Inbound", "qrCodeForClient": "QRCode for client {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "expired", + "fromFirstUse": "from first use" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index ef3b62de2..3e9dd99db 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -85,7 +85,6 @@ "uploaded": "Subido", "expiry": "Caducidad", "totalQuota": "Cuota total", - "individualLinks": "Enlaces individuales", "active": "Activo", "inactive": "Inactivo", "unlimited": "Ilimitado", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "IP del servidor DoH", "subIncyResolveDnsIpDesc": "IP del servidor DoH para el arranque.", "subIncyGroupRouting": "Enrutado", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "La privacidad de grupos está activada", + "tgPrivacyOnDesc": "Este bot responde solo en el chat privado, así que ignora los grupos a propósito. La privacidad de grupos solo decide si el bot puede leer los mensajes del grupo, cosa que no usa.", "subHostSortMode": "Orden de nodos de suscripción", "subHostSortInbound": "Orden de entradas (predeterminado)", "subHostSortHosts": "Orden de hosts", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Teclado cerrado!", "noResult": "❗ ¡Sin resultados!", "noQuery": "❌ ¡Consulta no encontrada! ¡Por favor, use el comando nuevamente!", "wentWrong": "❌ ¡Algo salió mal!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Comando desconocido", "pleaseChoose": "👇 Por favor elige:\r\n", "help": "🤖 ¡Bienvenido a este bot! Está diseñado para ofrecerte datos específicos del servidor y te permite hacer modificaciones según sea necesario.\r\n\r\n", - "start": "👋 Hola {{ .Firstname }}.\r\n", - "welcome": "🤖 Bienvenido al bot de gestión de {{ .Hostname }}.\r\n", - "status": "✅ ¡El bot está bien!", - "usage": "❗ ¡Por favor proporciona un texto para buscar!", "getID": "🆔 Tu ID: {{ .ID }}", "helpAdminCommands": "Para reiniciar Xray Core:\r\n/restart\r\n\r\nPara buscar un correo electrónico de cliente:\r\n/usage [Correo electrónico]\r\n\r\nPara buscar entradas (con estadísticas de cliente):\r\n/inbound [Observación]\r\n\r\nID de Chat de Telegram:\r\n/id", "helpClientCommands": "Para buscar estadísticas, utiliza el siguiente comando:\r\n/usage [Correo electrónico]\r\n\r\nID de Chat de Telegram:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Error en la operación.\r\n\r\nError: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core no está en ejecución.", "startDesc": "Mostrar el menú principal", - "helpDesc": "Ayuda del bot", - "statusDesc": "Comprobar el estado del bot", - "idDesc": "Mostrar tu ID de Telegram", - "usageDesc": "Ver el uso del cliente: /usage correo", - "inboundDesc": "Buscar entradas: /inbound nombre (admin)", - "restartDesc": "Reiniciar el núcleo de Xray (admin)", - "broadcastDesc": "Enviar un mensaje a todos los clientes (admin)", - "clearallDesc": "Restablecer el tráfico de todos los clientes (admin)" + "statusOk": "✅ ¡El bot funciona!" }, "messages": { "cpuThreshold": "El uso de CPU {{ .Percent }}% es mayor que el umbral {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Este enlace de invitación no es válido o ya se ha utilizado.\r\n", "inviteRateLimited": "⏳ Demasiados intentos de invitación. Inténtalo más tarde.\r\n", "inviteRateLimitedAdmin": "⚠️ Intentos de invitación bloqueados para {{ .User }} ({{ .ID }}): más de {{ .Limit }} intentos en una hora. Alguien podría estar adivinando IDs de suscripción.", - "inviteLink": "🔗 Enlace de invitación para {{ .Email }}:\r\n{{ .Link }}\r\n\r\nLa primera persona que lo abra quedará vinculada a este cliente.\r\n", "exhaustedCount": "🚨 Cantidad de Agotados {{ .Type }}:\r\n", "onlinesCount": "🌐 Clientes en línea: {{ .Count }}\r\n", "disabled": "🛑 Desactivado: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Actualizado en: {{ .Time }}\r\n\r\n", "yes": "✅ Sí", "no": "❌ No", - "received_email": "📧📥 Correo electrónico actualizado.", - "received_comment": "💬📥 Comentario actualizado.", "email_prompt": "📧 Correo electrónico predeterminado: {{ .ClientEmail }}\n\nIntroduce tu correo electrónico.", "comment_prompt": "💬 Comentario predeterminado: {{ .ClientComment }}\n\nIntroduce tu comentario.", + "tgid_prompt": "🆔 Usuario TG predeterminado: {{ .ClientTGID }}\n\nEnvía el id numérico del usuario para vincularlo a este cliente, o envía - / none para borrarlo.", "cancel": "❌ ¡Proceso cancelado! \n\nPuedes /start de nuevo en cualquier momento. 🔄", "error_add_client": "⚠️ Error:\n\n {{ .error }}", - "using_default_value": "Está bien, me quedaré con el valor predeterminado. 😊", "incorrect_input": "Tu entrada no es válida.\nLas frases deben ser continuas sin espacios.\nEjemplo correcto: aaaaaa\nEjemplo incorrecto: aaa aaa 🚫", "broadcastAskText": "📤 Difusión: envíame el mensaje para entregarlo a tus clientes — texto, foto, vídeo, archivo o un álbum completo.", "broadcastAlreadyRunning": "❗ Ya hay una difusión en curso, espera a que termine.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "El nodo {{ .Name }} está CAÍDO", "eventNodeUp": "El nodo {{ .Name }} está ACTIVO", "eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}", - "memoryThreshold": "Uso de memoria {{ .Percent }}% supera el umbral de {{ .Threshold }}%" + "memoryThreshold": "Uso de memoria {{ .Percent }}% supera el umbral de {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Servidor", + "category_clients": "👥 Clientes", + "category_traffic": "🚦 Tráfico", + "category_maintenance": "⚙️ Mantenimiento", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 Códigos QR:", + "qrLegendSubscription": "{{ .Code }}. Suscripción", + "qrLegendJSON": "{{ .Code }}. Suscripción JSON", + "qrLegendLink": "{{ .Code }}. Enlace de la app {{ .App }}", + "backupFailed": "No se pudo leer la base de datos; no se envió ninguna copia de seguridad.", + "privateOnly": "🤖 Este bot solo funciona en el chat privado. Ábrelo en un mensaje directo." }, "buttons": { "closeKeyboard": "❌ Cerrar Teclado", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ ¿Confirmar Limpiar IPs?", "confirmRemoveTGUser": "✅ ¿Confirmar Eliminar Usuario de Telegram?", "confirmToggle": "✅ ¿Confirmar habilitar/deshabilitar usuario?", - "dbBackup": "Obtener Copia de Seguridad de BD", - "serverUsage": "Uso del Servidor", - "getInbounds": "Obtener Entradas", - "depleteSoon": "Pronto se Agotará", - "clientUsage": "Obtener Uso", - "onlines": "Clientes en línea", - "commands": "Comandos", + "dbBackup": "💾 Obtener Copia de Seguridad de BD", + "serverUsage": "📊 Uso del Servidor", + "getInbounds": "🔌 Obtener Entradas", + "depleteSoon": "⚠️ Pronto se Agotará", + "onlines": "🟢 Clientes en línea", + "catServer": "🖥 Servidor", + "catClients": "👥 Clientes", + "catTraffic": "🚦 Tráfico", + "catMaintenance": "⚙️ Mantenimiento", "refresh": "🔄 Actualizar", "clearIPs": "❌ Limpiar IPs", "removeTGUser": "❌ Eliminar Usuario de Telegram", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Confirmar: {{ .Num }}", "confirmNumberAdd": "✅ Confirmar agregando: {{ .Num }}", "limitTraffic": "🚧 Límite de tráfico", - "getBanLogs": "Registros de prohibición", - "allClients": "Todos los Clientes", - "addClient": "Añadir cliente", + "getBanLogs": "📄 Registros de prohibición", + "allClients": "👥 Todos los Clientes", + "addClient": "➕ Añadir cliente", "submitDisable": "Enviar como deshabilitado ☑️", "submitEnable": "Enviar como habilitado ✅", "use_default": "🏷️ Usar por defecto", "change_email": "⚙️📧 Email", "change_comment": "⚙️💬 Comentario", - "ResetAllTraffics": "Reiniciar todo el tráfico", - "SortedTrafficUsageReport": "Informe de uso de tráfico ordenado" + "ResetAllTraffics": "♻️ Reiniciar todo el tráfico", + "SortedTrafficUsageReport": "📈 Informe de uso de tráfico ordenado", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Difusión", + "attachInbound": "Adjuntar entrada ({{ .Count }})", + "attachDone": "✅ Listo" }, "answers": { "broadcastStarted": "🚀 Difusión iniciada.", @@ -2682,6 +2703,36 @@ "chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}", "chooseInbound": "Elige un Inbound", "qrCodeForClient": "Código QR para el cliente {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "caducado", + "fromFirstUse": "desde el primer uso" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index c8b076509..c98d277f6 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -85,7 +85,6 @@ "uploaded": "آپلود", "expiry": "تاریخ پایان", "totalQuota": "حجم کلی", - "individualLinks": "لینک‌های تکی", "active": "فعال", "inactive": "غیرفعال", "unlimited": "نامحدود", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "آیپی سرور DoH", "subIncyResolveDnsIpDesc": "آیپی سرور DoH برای بوتاسترپ.", "subIncyGroupRouting": "مسیریابی", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "حریم خصوصی گروه فعال است", + "tgPrivacyOnDesc": "این ربات فقط در چت خصوصی پاسخ میدهد، بنابراین گروهها را عمداً نادیده میگیرد. حریم خصوصی گروه فقط تعیین میکند که ربات اجازه خواندن پیامهای گروه را دارد یا نه، که از آن استفاده نمیکند.", "subHostSortMode": "ترتیب گره‌های اشتراک", "subHostSortInbound": "ترتیب ورودی‌ها (پیش‌فرض)", "subHostSortHosts": "ترتیب میزبان‌ها", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ صفحه کلید بسته شد!", "noResult": "❗ نتیجه ای یافت نشد!", "noQuery": "❌ درخواست یافت نشد! لطفا دوباره تلاش کنید!", "wentWrong": "❌ مشکلی پیش آمد!", @@ -2508,10 +2511,6 @@ "unknown": "❗ دستور ناشناخته", "pleaseChoose": "👇 لطفاً انتخاب کنید:\r\n", "help": "🤖 به این ربات خوش آمدید! این ربات برای ارائه داده‌های خاص از سرور طراحی شده است و به شما امکان تغییرات لازم را می‌دهد.\r\n\r\n", - "start": "👋 سلام {{ .Firstname }}.\r\n", - "welcome": "🤖 به ربات مدیریت {{ .Hostname }} خوش آمدید.\r\n", - "status": "✅ ربات در حالت عادی است!", - "usage": "❗ لطفاً یک متن برای جستجو وارد کنید!", "getID": "🆔 شناسه شما: {{ .ID }}", "helpAdminCommands": "برای راه‌اندازی مجدد Xray Core:\r\n/restart\r\n\r\nبرای جستجوی ایمیل مشتری:\r\n/usage [ایمیل]\r\n\r\nبرای جستجوی ورودی‌ها (با آمار مشتری):\r\n/inbound [توضیحات]\r\n\r\nشناسه گفتگوی تلگرام:\r\n/id", "helpClientCommands": "برای جستجوی آمار، از دستور زیر استفاده کنید:\r\n/usage [ایمیل]\r\n\r\nشناسه گفتگوی تلگرام:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ خطا در عملیات.\r\n\r\nخطا: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core در حال اجرا نیست.", "startDesc": "نمایش منوی اصلی", - "helpDesc": "راهنمای ربات", - "statusDesc": "بررسی وضعیت ربات", - "idDesc": "نمایش شناسه تلگرام شما", - "usageDesc": "مشاهده مصرف کاربر: /usage ایمیل", - "inboundDesc": "جستجوی ورودی‌ها: /inbound نام (مدیر)", - "restartDesc": "راه‌اندازی مجدد هسته Xray (مدیر)", - "broadcastDesc": "ارسال پیام به همه کاربران (مدیر)", - "clearallDesc": "صفر کردن ترافیک همه کاربران (مدیر)" + "statusOk": "✅ ربات سالم است!" }, "messages": { "cpuThreshold": "بار ‌پردازنده {{ .Percent }}% بیشتر از آستانه است {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ این لینک دعوت نامعتبر است یا قبلاً استفاده شده است.\r\n", "inviteRateLimited": "⏳ تعداد تلاش‌های دعوت بیش از حد مجاز است. لطفاً بعداً دوباره امتحان کنید.\r\n", "inviteRateLimitedAdmin": "⚠️ تلاش‌های دعوت برای {{ .User }} ({{ .ID }}) مسدود شد: بیش از {{ .Limit }} تلاش در یک ساعت. ممکن است کسی در حال حدس زدن شناسه‌های اشتراک باشد.", - "inviteLink": "🔗 لینک دعوت برای {{ .Email }}:\r\n{{ .Link }}\r\n\r\nاولین کسی که آن را باز کند به این کاربر متصل می‌شود.\r\n", "exhaustedCount": "🚨 تعداد {{ .Type }} به‌اتمام‌رسیده‌است:\r\n", "onlinesCount": "🌐 کاربران‌آنلاین: {{ .Count }}\r\n", "disabled": "🛑 غیرفعال: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 تازه‌سازی شده در: {{ .Time }}\r\n\r\n", "yes": "✅ بله", "no": "❌ خیر", - "received_email": "📧📥 ایمیل به‌روزرسانی شد.", - "received_comment": "💬📥 نظر به‌روزرسانی شد.", "email_prompt": "📧 ایمیل پیش‌فرض: {{ .ClientEmail }}\n\nایمیل خود را وارد کنید.", "comment_prompt": "💬 نظر پیش‌فرض: {{ .ClientComment }}\n\nنظر خود را وارد کنید.", + "tgid_prompt": "🆔 کاربر پیشفرض TG: {{ .ClientTGID }}\n\nشناسه عددی کاربر را برای اتصال به این کلاینت بفرستید، یا برای پاک کردن - / none را بفرستید.", "cancel": "❌ فرآیند لغو شد! \n\nمی‌توانید هر زمان که خواستید /start را دوباره اجرا کنید. 🔄", "error_add_client": "⚠️ خطا:\n\n {{ .error }}", - "using_default_value": "باشه، از مقدار پیش‌فرض استفاده می‌کنم. 😊", "incorrect_input": "ورودی شما معتبر نیست.\nعبارت‌ها باید بدون فاصله باشند.\nمثال صحیح: aaaaaa\nمثال نادرست: aaa aaa 🚫", "broadcastAskText": "📤 ارسال گروهی: پیام خود را برای ارسال به کاربران بفرستید — متن، عکس، ویدیو، فایل یا یک آلبوم کامل.", "broadcastAlreadyRunning": "❗ یک ارسال گروهی در حال اجراست، تا پایان آن صبر کنید.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "نود {{ .Name }} قطع است", "eventNodeUp": "نود {{ .Name }} وصل است", "eventLoginFallback": "ورود ناموفق از {{ .Source }}", - "memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است" + "memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 سرور", + "category_clients": "👥 کاربران", + "category_traffic": "🚦 ترافیک", + "category_maintenance": "⚙️ نگهداری", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 کدهای QR:", + "qrLegendSubscription": "{{ .Code }}. اشتراک", + "qrLegendJSON": "{{ .Code }}. اشتراک JSON", + "qrLegendLink": "{{ .Code }}. لینک برنامه {{ .App }}", + "backupFailed": "خواندن پایگاه داده ممکن نشد؛ هیچ نسخه پشتیبانی ارسال نشد.", + "privateOnly": "🤖 این ربات فقط در چت خصوصی کار میکند. لطفاً آن را در پیام مستقیم باز کنید." }, "buttons": { "closeKeyboard": "❌ بستن کیبورد", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ تأیید پاک‌سازی آدرس‌های آی‌پی؟", "confirmRemoveTGUser": "✅ تأیید حذف کاربر تلگرام؟", "confirmToggle": "✅ تایید فعال/غیرفعال کردن کاربر؟", - "dbBackup": "دریافت پشتیبان", - "serverUsage": "استفاده از سیستم", - "getInbounds": "دریافت ورودی‌ها", - "depleteSoon": "به‌زودی به پایان خواهد رسید", - "clientUsage": "دریافت آمار کاربر", - "onlines": "کاربران آنلاین", - "commands": "دستورات", + "dbBackup": "💾 دریافت پشتیبان", + "serverUsage": "📊 استفاده از سیستم", + "getInbounds": "🔌 دریافت ورودی‌ها", + "depleteSoon": "⚠️ به‌زودی به پایان خواهد رسید", + "onlines": "🟢 کاربران آنلاین", + "catServer": "🖥 سرور", + "catClients": "👥 کاربران", + "catTraffic": "🚦 ترافیک", + "catMaintenance": "⚙️ نگهداری", "refresh": "🔄 تازه‌سازی", "clearIPs": "❌ پاک‌سازی آدرس‌ها", "removeTGUser": "❌ حذف کاربر تلگرام", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ تایید: {{ .Num }}", "confirmNumberAdd": "✅ تایید اضافه کردن: {{ .Num }}", "limitTraffic": "🚧 محدودیت ترافیک", - "getBanLogs": "گزارش های بلوک را دریافت کنید", - "allClients": "همه مشتریان", - "addClient": "افزودن مشتری", + "getBanLogs": "📄 گزارش های بلوک را دریافت کنید", + "allClients": "👥 همه مشتریان", + "addClient": "➕ افزودن مشتری", "submitDisable": "ارسال به عنوان غیرفعال ☑️", "submitEnable": "ارسال به عنوان فعال ✅", "use_default": "🏷️ استفاده از پیش‌فرض", "change_email": "⚙️📧 ایمیل", "change_comment": "⚙️💬 نظر", - "ResetAllTraffics": "بازنشانی همه ترافیک‌ها", - "SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده" + "ResetAllTraffics": "♻️ بازنشانی همه ترافیک‌ها", + "SortedTrafficUsageReport": "📈 گزارش استفاده از ترافیک مرتب‌شده", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 همگانی", + "attachInbound": "افزودن ورودی ({{ .Count }})", + "attachDone": "✅ انجام شد" }, "answers": { "broadcastStarted": "🚀 ارسال گروهی آغاز شد.", @@ -2682,6 +2703,36 @@ "chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید", "chooseInbound": "یک ورودی انتخاب کنید", "qrCodeForClient": "کد QR برای کاربر {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "منقضی", + "fromFirstUse": "از اولین استفاده" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 4bd9b7970..8aa962362 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -85,7 +85,6 @@ "uploaded": "Diunggah", "expiry": "Kedaluwarsa", "totalQuota": "Kuota total", - "individualLinks": "Tautan individual", "active": "Aktif", "inactive": "Nonaktif", "unlimited": "Tanpa batas", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "IP server DoH", "subIncyResolveDnsIpDesc": "IP server DoH untuk bootstrap.", "subIncyGroupRouting": "Perutean", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "Privasi grup aktif", + "tgPrivacyOnDesc": "Bot ini hanya menjawab di obrolan pribadi, jadi grup sengaja diabaikan. Privasi grup hanya menentukan apakah bot boleh membaca pesan grup, yang tidak digunakan.", "subHostSortMode": "Urutan node langganan", "subHostSortInbound": "Urutan inbound (bawaan)", "subHostSortHosts": "Urutan host", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Keyboard ditutup!", "noResult": "❗ Tidak ada hasil!", "noQuery": "❌ Kueri tidak ditemukan! Silakan gunakan perintah lagi!", "wentWrong": "❌ Terjadi kesalahan!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Perintah tidak dikenal.", "pleaseChoose": "👇 Harap pilih:\r\n", "help": "🤖 Selamat datang di bot ini! Ini dirancang untuk menyediakan data tertentu dari panel web dan memungkinkan Anda melakukan modifikasi sesuai kebutuhan.\r\n\r\n", - "start": "👋 Halo {{ .Firstname }}.\r\n", - "welcome": "🤖 Selamat datang di {{.Hostname }} bot managemen.\r\n", - "status": "✅ Bot dalam keadaan baik!", - "usage": "❗ Harap berikan teks untuk mencari!", "getID": "🆔 ID Anda: {{ .ID }}", "helpAdminCommands": "Untuk memulai ulang Xray Core:\r\n/restart\r\n\r\nUntuk mencari email klien:\r\n/usage [Email]\r\n\r\nUntuk mencari inbound (dengan statistik klien):\r\n/inbound [Catatan]\r\n\r\nID Obrolan Telegram:\r\n/id", "helpClientCommands": "Untuk mencari statistik, gunakan perintah berikut:\r\n/usage [Email]\r\n\r\nID Obrolan Telegram:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Kesalahan dalam operasi.\r\n\r\nError: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core tidak berjalan.", "startDesc": "Tampilkan menu utama", - "helpDesc": "Bantuan bot", - "statusDesc": "Periksa status bot", - "idDesc": "Tampilkan ID Telegram Anda", - "usageDesc": "Lihat pemakaian klien: /usage email", - "inboundDesc": "Cari inbound: /inbound nama (admin)", - "restartDesc": "Mulai ulang inti Xray (admin)", - "broadcastDesc": "Kirim pesan ke semua klien (admin)", - "clearallDesc": "Reset trafik semua klien (admin)" + "statusOk": "✅ Bot berfungsi!" }, "messages": { "cpuThreshold": "Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Tautan undangan ini tidak valid atau sudah digunakan.\r\n", "inviteRateLimited": "⏳ Terlalu banyak percobaan undangan. Silakan coba lagi nanti.\r\n", "inviteRateLimitedAdmin": "⚠️ Percobaan undangan diblokir untuk {{ .User }} ({{ .ID }}): lebih dari {{ .Limit }} percobaan dalam satu jam. Seseorang mungkin sedang menebak ID langganan.", - "inviteLink": "🔗 Tautan undangan untuk {{ .Email }}:\r\n{{ .Link }}\r\n\r\nOrang pertama yang membukanya akan tertaut ke klien ini.\r\n", "exhaustedCount": "🚨 Jumlah Habis {{ .Type }}:\r\n", "onlinesCount": "🌐 Klien Online: {{ .Count }}\r\n", "disabled": "🛑 Dinonaktifkan: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Diperbarui Pada: {{ .Time }}\r\n\r\n", "yes": "✅ Ya", "no": "❌ Tidak", - "received_email": "📧📥 Email diperbarui.", - "received_comment": "💬📥 Komentar diperbarui.", "email_prompt": "📧 Email Default: {{ .ClientEmail }}\n\nMasukkan email Anda.", "comment_prompt": "💬 Komentar Default: {{ .ClientComment }}\n\nMasukkan komentar Anda.", + "tgid_prompt": "🆔 Pengguna TG default: {{ .ClientTGID }}\n\nKirim id numerik pengguna untuk melampirkan ke klien ini, atau kirim - / none untuk menghapus.", "cancel": "❌ Proses Dibatalkan! \n\nAnda dapat /start lagi kapan saja. 🔄", "error_add_client": "⚠️ Error:\n\n {{ .error }}", - "using_default_value": "Oke, saya akan tetap menggunakan nilai default. 😊", "incorrect_input": "Masukan Anda tidak valid.\nFrasa harus berlanjut tanpa spasi.\nContoh benar: aaaaaa\nContoh salah: aaa aaa 🚫", "broadcastAskText": "📡 Siaran: kirimkan pesan untuk dikirimkan ke klien Anda — teks, foto, video, file, atau satu album lengkap.", "broadcastAlreadyRunning": "❗ Siaran sedang berjalan, tunggu hingga selesai.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "Node {{ .Name }} MATI", "eventNodeUp": "Node {{ .Name }} AKTIF", "eventLoginFallback": "Gagal masuk dari {{ .Source }}", - "memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%" + "memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Server", + "category_clients": "👥 Klien", + "category_traffic": "🚦 Trafik", + "category_maintenance": "⚙️ Pemeliharaan", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 Kode QR:", + "qrLegendSubscription": "{{ .Code }}. Langganan", + "qrLegendJSON": "{{ .Code }}. Langganan JSON", + "qrLegendLink": "{{ .Code }}. Tautan aplikasi {{ .App }}", + "backupFailed": "Tidak dapat membaca basis data; tidak ada cadangan yang dikirim.", + "privateOnly": "🤖 Bot ini hanya berfungsi di obrolan pribadi. Silakan buka di pesan langsung." }, "buttons": { "closeKeyboard": "❌ Tutup Papan Ketik", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ Konfirmasi Hapus IPs?", "confirmRemoveTGUser": "✅ Konfirmasi Hapus Pengguna Telegram?", "confirmToggle": "✅ Konfirmasi Aktifkan/Nonaktifkan Pengguna?", - "dbBackup": "Dapatkan Cadangan DB", - "serverUsage": "Penggunaan Server", - "getInbounds": "Dapatkan Inbounds", - "depleteSoon": "Habis Sebentar", - "clientUsage": "Dapatkan Penggunaan", - "onlines": "Klien Online", - "commands": "Perintah", + "dbBackup": "💾 Dapatkan Cadangan DB", + "serverUsage": "📊 Penggunaan Server", + "getInbounds": "🔌 Dapatkan Inbounds", + "depleteSoon": "⚠️ Habis Sebentar", + "onlines": "🟢 Klien Online", + "catServer": "🖥 Server", + "catClients": "👥 Klien", + "catTraffic": "🚦 Trafik", + "catMaintenance": "⚙️ Pemeliharaan", "refresh": "🔄 Perbarui", "clearIPs": "❌ Hapus IPs", "removeTGUser": "❌ Hapus Pengguna Telegram", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Konfirmasi: {{ .Num }}", "confirmNumberAdd": "✅ Konfirmasi menambahkan: {{ .Num }}", "limitTraffic": "🚧 Batas Lalu Lintas", - "getBanLogs": "Dapatkan Log Pemblokiran", - "allClients": "Semua Klien", - "addClient": "Tambah Klien", + "getBanLogs": "📄 Dapatkan Log Pemblokiran", + "allClients": "👥 Semua Klien", + "addClient": "➕ Tambah Klien", "submitDisable": "Kirim Sebagai Nonaktif ☑️", "submitEnable": "Kirim Sebagai Aktif ✅", "use_default": "🏷️ Gunakan Default", "change_email": "⚙️📧 Email", "change_comment": "⚙️💬 Komentar", - "ResetAllTraffics": "Reset Semua Lalu Lintas", - "SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut" + "ResetAllTraffics": "♻️ Reset Semua Lalu Lintas", + "SortedTrafficUsageReport": "📈 Laporan Penggunaan Lalu Lintas yang Terurut", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Siaran", + "attachInbound": "Lampirkan inbound ({{ .Count }})", + "attachDone": "✅ Selesai" }, "answers": { "broadcastStarted": "🚀 Siaran dimulai.", @@ -2682,6 +2703,36 @@ "chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}", "chooseInbound": "Pilih Inbound", "qrCodeForClient": "Kode QR untuk klien {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "kedaluwarsa", + "fromFirstUse": "sejak pemakaian pertama" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index a94e59c2b..edf1f48c5 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -85,7 +85,6 @@ "uploaded": "アップロード", "expiry": "有効期限", "totalQuota": "合計クォータ", - "individualLinks": "個別リンク", "active": "有効", "inactive": "無効", "unlimited": "無制限", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "DoH サーバー IP", "subIncyResolveDnsIpDesc": "ブートストラップ用の DoH サーバー IP。", "subIncyGroupRouting": "ルーティング", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "グループプライバシーが有効です", + "tgPrivacyOnDesc": "このボットは個人チャットでのみ応答するため、グループは意図的に無視します。グループプライバシーはボットがグループメッセージを読めるかどうかを決めるだけで、実際には使用しません。", "subHostSortMode": "サブスクリプションのノード順序", "subHostSortInbound": "インバウンド順(既定)", "subHostSortHosts": "ホスト順", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ キーボードを閉じました!", "noResult": "❗ 結果がありません!", "noQuery": "❌ クエリが見つかりません!コマンドを再利用してください!", "wentWrong": "❌ 何かがうまくいかなかった!", @@ -2508,10 +2511,6 @@ "unknown": "❗ 不明なコマンド", "pleaseChoose": "👇 選択してください:\r\n", "help": "🤖 このボットをご利用いただきありがとうございます!サーバーから特定のデータを提供し、必要な変更を行うことができます。\r\n\r\n", - "start": "👋 こんにちは、{{ .Firstname }}。\r\n", - "welcome": "🤖 {{ .Hostname }} 管理ボットへようこそ。\r\n", - "status": "✅ ボットは正常に動作しています!", - "usage": "❗ 検索するテキストを入力してください!", "getID": "🆔 あなたのIDは:{{ .ID }}", "helpAdminCommands": "Xray Coreを再起動するには:\r\n/restart\r\n\r\nクライアントの電子メールを検索するには:\r\n/usage [電子メール]\r\n\r\nインバウンド(クライアントの統計情報を含む)を検索するには:\r\n/inbound [備考]\r\n\r\nTelegramチャットID:\r\n/id", "helpClientCommands": "統計情報を検索するには、次のコマンドを使用してください:\r\n/usage [電子メール]\r\n\r\nTelegramチャットID:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ 操作エラー。\r\n\r\nエラー: {{ .Error }}", "xrayNotRunning": "❗ Xray Core は動作していません。", "startDesc": "メインメニューを表示", - "helpDesc": "ボットのヘルプ", - "statusDesc": "ボットの状態を確認", - "idDesc": "Telegram IDを表示", - "usageDesc": "クライアント使用量を表示: /usage メール", - "inboundDesc": "インバウンド検索: /inbound 備考(管理者)", - "restartDesc": "Xray コアを再起動(管理者)", - "broadcastDesc": "全クライアントへ一括送信(管理者)", - "clearallDesc": "全クライアントのトラフィックをリセット(管理者)" + "statusOk": "✅ ボットは正常です!" }, "messages": { "cpuThreshold": "CPU使用率は{{ .Percent }}%、しきい値{{ .Threshold }}%を超えました", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ この招待リンクは無効か、すでに使用されています。\r\n", "inviteRateLimited": "⏳ 招待の試行回数が多すぎます。しばらくしてからもう一度お試しください。\r\n", "inviteRateLimitedAdmin": "⚠️ {{ .User }} ({{ .ID }}) の招待の試行をブロックしました:1 時間に {{ .Limit }} 回を超えました。誰かがサブスクリプション ID を推測している可能性があります。", - "inviteLink": "🔗 {{ .Email }} の招待リンク:\r\n{{ .Link }}\r\n\r\n最初に開いた人がこのクライアントにリンクされます。\r\n", "exhaustedCount": "🚨 消耗済みの {{ .Type }} 数量:\r\n", "onlinesCount": "🌐 オンラインクライアント:{{ .Count }}\r\n", "disabled": "🛑 無効化:{{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 更新時間:{{ .Time }}\r\n\r\n", "yes": "✅ はい", "no": "❌ いいえ", - "received_email": "📧📥 メールが更新されました。", - "received_comment": "💬📥 コメントが更新されました。", "email_prompt": "📧 デフォルトメール: {{ .ClientEmail }}\n\nメールを入力してください。", "comment_prompt": "💬 デフォルトコメント: {{ .ClientComment }}\n\nコメントを入力してください。", + "tgid_prompt": "🆔 既定のTGユーザー: {{ .ClientTGID }}\n\nこのクライアントに紐付けるユーザーの数値idを送信してください。解除するには - / none を送信します。", "cancel": "❌ プロセスがキャンセルされました!\n\nいつでも /start で再開できます。 🔄", "error_add_client": "⚠️ エラー:\n\n {{ .error }}", - "using_default_value": "わかりました、デフォルト値を使用します。 😊", "incorrect_input": "入力が無効です。\nフレーズはスペースなしで続けて入力してください。\n正しい例: aaaaaa\n間違った例: aaa aaa 🚫", "broadcastAskText": "📤 一括送信:クライアントに配信するメッセージを送ってください — テキスト、写真、動画、ファイル、アルバム全体も OK。", "broadcastAlreadyRunning": "❗ 一括送信はすでに実行中です。完了までお待ちください。", @@ -2610,7 +2599,23 @@ "eventNodeDown": "ノード {{ .Name }} がダウンしています", "eventNodeUp": "ノード {{ .Name }} が復旧しました", "eventLoginFallback": "{{ .Source }} からのログインに失敗しました", - "memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました" + "memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 サーバー", + "category_clients": "👥 クライアント", + "category_traffic": "🚦 トラフィック", + "category_maintenance": "⚙️ メンテナンス", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 QRコード:", + "qrLegendSubscription": "{{ .Code }}. サブスクリプション", + "qrLegendJSON": "{{ .Code }}. JSON サブスクリプション", + "qrLegendLink": "{{ .Code }}. アプリのリンク {{ .App }}", + "backupFailed": "データベースを読み取れませんでした。バックアップは送信されていません。", + "privateOnly": "🤖 このボットは個人チャットでのみ動作します。ダイレクトメッセージで開いてください。" }, "buttons": { "closeKeyboard": "❌ キーボードを閉じる", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ IPをクリアしますか?", "confirmRemoveTGUser": "✅ Telegramユーザーを削除しますか?", "confirmToggle": "✅ ユーザーを有効/無効にしますか?", - "dbBackup": "データベースバックアップを取得", - "serverUsage": "サーバーの使用状況", - "getInbounds": "インバウンド情報を取得", - "depleteSoon": "間もなく消耗", - "clientUsage": "使用状況を取得", - "onlines": "オンラインクライアント", - "commands": "コマンド", + "dbBackup": "💾 データベースバックアップを取得", + "serverUsage": "📊 サーバーの使用状況", + "getInbounds": "🔌 インバウンド情報を取得", + "depleteSoon": "⚠️ 間もなく消耗", + "onlines": "🟢 オンラインクライアント", + "catServer": "🖥 サーバー", + "catClients": "👥 クライアント", + "catTraffic": "🚦 トラフィック", + "catMaintenance": "⚙️ メンテナンス", "refresh": "🔄 更新", "clearIPs": "❌ IPをクリア", "removeTGUser": "❌ Telegramユーザーを削除", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ 確認: {{ .Num }}", "confirmNumberAdd": "✅ 追加を確認:{{ .Num }}", "limitTraffic": "🚧 トラフィック制限", - "getBanLogs": "禁止ログ", - "allClients": "すべてのクライアント", - "addClient": "クライアントを追加", + "getBanLogs": "📄 禁止ログ", + "allClients": "👥 すべてのクライアント", + "addClient": "➕ クライアントを追加", "submitDisable": "無効として送信 ☑️", "submitEnable": "有効として送信 ✅", "use_default": "🏷️ デフォルトを使用", "change_email": "⚙️📧 メール", "change_comment": "⚙️💬 コメント", - "ResetAllTraffics": "すべてのトラフィックをリセット", - "SortedTrafficUsageReport": "ソートされたトラフィック使用レポート" + "ResetAllTraffics": "♻️ すべてのトラフィックをリセット", + "SortedTrafficUsageReport": "📈 ソートされたトラフィック使用レポート", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 一斉送信", + "attachInbound": "インバウンドを追加 ({{ .Count }})", + "attachDone": "✅ 完了" }, "answers": { "broadcastStarted": "🚀 一括送信を開始しました。", @@ -2682,6 +2703,36 @@ "chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択", "chooseInbound": "インバウンドを選択", "qrCodeForClient": "クライアント {{ .Email }} のQRコード:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "期限切れ", + "fromFirstUse": "初回使用から" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index be1ee8754..3b1d5d3fa 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -85,7 +85,6 @@ "uploaded": "Enviado", "expiry": "Validade", "totalQuota": "Cota total", - "individualLinks": "Links individuais", "active": "Ativo", "inactive": "Inativo", "unlimited": "Ilimitado", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "IP do servidor DoH", "subIncyResolveDnsIpDesc": "IP do servidor DoH para bootstrap.", "subIncyGroupRouting": "Roteamento", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "A privacidade em grupos está ativada", + "tgPrivacyOnDesc": "Este bot responde apenas no chat privado, então ignora os grupos de propósito. A privacidade em grupos só decide se o bot pode ler mensagens do grupo, o que ele não usa.", "subHostSortMode": "Ordem dos nós da assinatura", "subHostSortInbound": "Ordem das entradas (padrão)", "subHostSortHosts": "Ordem dos hosts", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Teclado fechado!", "noResult": "❗ Nenhum resultado!", "noQuery": "❌ Consulta não encontrada! Por favor, use o comando novamente!", "wentWrong": "❌ Algo deu errado!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Comando desconhecido.", "pleaseChoose": "👇 Escolha:\r\n", "help": "🤖 Bem-vindo a este bot! Ele foi projetado para oferecer dados específicos do painel da web e permite que você faça as modificações necessárias.\r\n\r\n", - "start": "👋 Olá {{ .Firstname }}.\r\n", - "welcome": "🤖 Bem-vindo ao bot de gerenciamento do {{ .Hostname }}.\r\n", - "status": "✅ Bot está OK!", - "usage": "❗ Por favor, forneça um texto para pesquisar!", "getID": "🆔 Seu ID: {{ .ID }}", "helpAdminCommands": "Para reiniciar o Xray Core:\r\n/restart\r\n\r\nPara pesquisar por um email de cliente:\r\n/usage [Email]\r\n\r\nPara pesquisar por inbounds (com estatísticas do cliente):\r\n/inbound [Remark]\r\n\r\nTelegram Chat ID:\r\n/id", "helpClientCommands": "Para pesquisar por estatísticas, use o seguinte comando:\r\n\r\n/usage [Email]\r\n\r\nTelegram Chat ID:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Erro na operação.\r\n\r\nErro: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core não está em execução.", "startDesc": "Mostrar menu principal", - "helpDesc": "Ajuda do bot", - "statusDesc": "Verificar status do bot", - "idDesc": "Mostrar seu ID do Telegram", - "usageDesc": "Ver o uso do cliente: /usage email", - "inboundDesc": "Buscar entradas: /inbound nome (admin)", - "restartDesc": "Reiniciar o núcleo Xray (admin)", - "broadcastDesc": "Enviar uma mensagem para todos os clientes (admin)", - "clearallDesc": "Zerar o tráfego de todos os clientes (admin)" + "statusOk": "✅ O bot está OK!" }, "messages": { "cpuThreshold": "A carga da CPU {{ .Percent }}% excede o limite de {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Este link de convite é inválido ou já foi utilizado.\r\n", "inviteRateLimited": "⏳ Muitas tentativas de convite. Tente novamente mais tarde.\r\n", "inviteRateLimitedAdmin": "⚠️ Tentativas de convite bloqueadas para {{ .User }} ({{ .ID }}): mais de {{ .Limit }} tentativas em uma hora. Alguém pode estar tentando adivinhar IDs de assinatura.", - "inviteLink": "🔗 Link de convite para {{ .Email }}:\r\n{{ .Link }}\r\n\r\nA primeira pessoa que abri-lo será vinculada a este cliente.\r\n", "exhaustedCount": "🚨 Contagem de {{ .Type }} esgotado:\r\n", "onlinesCount": "🌐 Clientes online: {{ .Count }}\r\n", "disabled": "🛑 Desativado: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Atualizado em: {{ .Time }}\r\n\r\n", "yes": "✅ Sim", "no": "❌ Não", - "received_email": "📧📥 E-mail atualizado.", - "received_comment": "💬📥 Comentário atualizado.", "email_prompt": "📧 E-mail Padrão: {{ .ClientEmail }}\n\nDigite seu e-mail.", "comment_prompt": "💬 Comentário Padrão: {{ .ClientComment }}\n\nDigite seu comentário.", + "tgid_prompt": "🆔 Usuário TG padrão: {{ .ClientTGID }}\n\nEnvie o id numérico do usuário para vincular a este cliente, ou envie - / none para limpar.", "cancel": "❌ Processo Cancelado! \n\nVocê pode iniciar novamente a qualquer momento com /start. 🔄", "error_add_client": "⚠️ Erro:\n\n {{ .error }}", - "using_default_value": "Tudo bem, vou manter o valor padrão. 😊", "incorrect_input": "Sua entrada não é válida.\nAs frases devem ser contínuas, sem espaços.\nExemplo correto: aaaaaa\nExemplo incorreto: aaa aaa 🚫", "broadcastAskText": "📤 Transmissão: envie-me a mensagem para entregar aos seus clientes — texto, foto, vídeo, arquivo ou um álbum inteiro.", "broadcastAlreadyRunning": "❗ Já existe uma transmissão em andamento, aguarde a conclusão.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "O nó {{ .Name }} está INATIVO", "eventNodeUp": "O nó {{ .Name }} está ATIVO", "eventLoginFallback": "Falha de login a partir de {{ .Source }}", - "memoryThreshold": "Uso de memória {{ .Percent }}% excede o limite de {{ .Threshold }}%" + "memoryThreshold": "Uso de memória {{ .Percent }}% excede o limite de {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Servidor", + "category_clients": "👥 Clientes", + "category_traffic": "🚦 Tráfego", + "category_maintenance": "⚙️ Manutenção", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 Códigos QR:", + "qrLegendSubscription": "{{ .Code }}. Assinatura", + "qrLegendJSON": "{{ .Code }}. Assinatura JSON", + "qrLegendLink": "{{ .Code }}. Link do app {{ .App }}", + "backupFailed": "Não foi possível ler o banco de dados; nenhum backup foi enviado.", + "privateOnly": "🤖 Este bot funciona apenas no chat privado. Abra-o em uma mensagem direta." }, "buttons": { "closeKeyboard": "❌ Fechar teclado", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ Confirmar limpar IPs?", "confirmRemoveTGUser": "✅ Confirmar remover usuário do Telegram?", "confirmToggle": "✅ Confirmar ativar/desativar usuário?", - "dbBackup": "Obter backup do DB", - "serverUsage": "Uso do servidor", - "getInbounds": "Obter Inbounds", - "depleteSoon": "Esgotar em breve", - "clientUsage": "Obter uso", - "onlines": "Clientes online", - "commands": "Comandos", + "dbBackup": "💾 Obter backup do DB", + "serverUsage": "📊 Uso do servidor", + "getInbounds": "🔌 Obter Inbounds", + "depleteSoon": "⚠️ Esgotar em breve", + "onlines": "🟢 Clientes online", + "catServer": "🖥 Servidor", + "catClients": "👥 Clientes", + "catTraffic": "🚦 Tráfego", + "catMaintenance": "⚙️ Manutenção", "refresh": "🔄 Atualizar", "clearIPs": "❌ Limpar IPs", "removeTGUser": "❌ Remover usuário do Telegram", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Confirmar: {{ .Num }}", "confirmNumberAdd": "✅ Confirmar adicionar: {{ .Num }}", "limitTraffic": "🚧 Limite de tráfego", - "getBanLogs": "Obter logs de banimento", - "allClients": "Todos os clientes", - "addClient": "Adicionar Cliente", + "getBanLogs": "📄 Obter logs de banimento", + "allClients": "👥 Todos os clientes", + "addClient": "➕ Adicionar Cliente", "submitDisable": "Enviar como Desativado ☑️", "submitEnable": "Enviar como Ativado ✅", "use_default": "🏷️ Usar padrão", "change_email": "⚙️📧 Email", "change_comment": "⚙️💬 Comentário", - "ResetAllTraffics": "Redefinir Todo o Tráfego", - "SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado" + "ResetAllTraffics": "♻️ Redefinir Todo o Tráfego", + "SortedTrafficUsageReport": "📈 Relatório de Uso de Tráfego Ordenado", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Transmissão", + "attachInbound": "Anexar inbound ({{ .Count }})", + "attachDone": "✅ Concluído" }, "answers": { "broadcastStarted": "🚀 Transmissão iniciada.", @@ -2682,6 +2703,36 @@ "chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}", "chooseInbound": "Escolha um Inbound", "qrCodeForClient": "QR Code para o cliente {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "expirado", + "fromFirstUse": "desde o primeiro uso" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 702e42481..f0b6a3214 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -85,7 +85,6 @@ "uploaded": "Отправлено", "expiry": "Срок действия", "totalQuota": "Общий лимит", - "individualLinks": "Индивидуальные ссылки", "active": "Активна", "inactive": "Неактивна", "unlimited": "Неограниченно", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "IP DoH-сервера", "subIncyResolveDnsIpDesc": "IP DoH-сервера, нужен для bootstrap.", "subIncyGroupRouting": "Маршрутизация", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "Режим приватности включён", + "tgPrivacyOnDesc": "Бот работает только в личных сообщениях, поэтому группы он игнорирует намеренно. Приватность в группах решает лишь то, может ли бот читать сообщения группы, — а он это не использует.", "subHostSortMode": "Порядок узлов подписки", "subHostSortInbound": "Порядок входящих подключений (по умолчанию)", "subHostSortHosts": "Порядок хостов", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Клавиатура закрыта.", "noResult": "❗ Нет результатов.", "noQuery": "❌ Запрос не найден. Пожалуйста, повторите команду.", "wentWrong": "❌ Что-то пошло не так...", @@ -2508,10 +2511,6 @@ "unknown": "❗ Неизвестная команда", "pleaseChoose": "👇 Пожалуйста, выберите:\r\n", "help": "🤖 Добро пожаловать! Этот бот предназначен для предоставления вам данных с сервера и позволяет вносить изменения на него.\r\n\r\n", - "start": "👋 Привет, {{ .Firstname }}.\r\n", - "welcome": "🤖 Добро пожаловать в бота управления {{ .Hostname }}!\r\n", - "status": "✅ Бот функционирует нормально.", - "usage": "❗ Пожалуйста, укажите email для поиска.", "getID": "🆔 Ваш User ID: {{ .ID }}", "helpAdminCommands": "🔃 Для перезапуска Xray Core:\r\n/restart\r\n\r\n🔎 Для поиска клиента по email:\r\n/usage [Email]\r\n\r\n📊 Для поиска входящих подключений (со статистикой клиентов):\r\n/inbound [имя подключения]\r\n\r\n🆔 Ваш Telegram User ID:\r\n/id", "helpClientCommands": "💲 Для просмотра информации о вашей подписке используйте команду:\r\n/usage [Email]\r\n\r\n🆔 Ваш Telegram User ID:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Ошибка при перезапуске Xray-core.\r\n\r\nОшибка: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core не запущен.", "startDesc": "Показать главное меню", - "helpDesc": "Справка по боту", - "statusDesc": "Проверить статус бота", - "idDesc": "Показать ваш Telegram ID", - "usageDesc": "Показать трафик клиента: /usage email", - "inboundDesc": "Поиск входящих: /inbound имя (админ)", - "restartDesc": "Перезапустить ядро Xray (админ)", - "broadcastDesc": "Массовая рассылка всем клиентам (админ)", - "clearallDesc": "Сбросить трафик всех клиентов (админ)" + "statusOk": "✅ Бот в порядке!" }, "messages": { "cpuThreshold": "Загрузка процессора составляет {{ .Percent }}%, что превышает пороговое значение {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Эта ссылка-приглашение недействительна или уже использована.\r\n", "inviteRateLimited": "⏳ Слишком много попыток приглашения. Попробуйте позже.\r\n", "inviteRateLimitedAdmin": "⚠️ Попытки приглашения заблокированы для {{ .User }} ({{ .ID }}): более {{ .Limit }} попыток за час. Возможно, кто-то подбирает ID подписок.", - "inviteLink": "🔗 Ссылка-приглашение для {{ .Email }}:\r\n{{ .Link }}\r\n\r\nПервый, кто её откроет, будет привязан к этому клиенту.\r\n", "exhaustedCount": "🚨 Количество исчерпанных {{ .Type }}:\r\n", "onlinesCount": "🌐 Клиентов онлайн: {{ .Count }}\r\n", "disabled": "🛑 Отключено: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Обновлено: {{ .Time }}\r\n\r\n", "yes": "✅ Да", "no": "❌ Нет", - "received_email": "📧📥 Email обновлен.", - "received_comment": "💬📥 Комментарий обновлён.", "email_prompt": "📧 Стандартный email: {{ .ClientEmail }}\n\nВведите ваш email.", "comment_prompt": "💬 Стандартный комментарий: {{ .ClientComment }}\n\nВведите ваш комментарий.", + "tgid_prompt": "🆔 Стандартный TG-юзер: {{ .ClientTGID }}\n\nОтправьте числовой id пользователя, чтобы привязать к клиенту, либо - / none чтобы очистить.", "cancel": "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄", "error_add_client": "⚠️ Ошибка:\n\n {{ .error }}", - "using_default_value": "Используется значение по умолчанию👌", "incorrect_input": "Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫", "broadcastAskText": "📤 Рассылка: пришлите сообщение для ваших клиентов — текст, фото, видео, файл или целый альбом.", "broadcastAlreadyRunning": "❗ Рассылка уже идёт, дождитесь её завершения.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН", "eventNodeUp": "Узел {{ .Name }} В СЕТИ", "eventLoginFallback": "Неудачный вход с {{ .Source }}", - "memoryThreshold": "🔴 Использование памяти {{ .Percent }}% превышает пороговое значение {{ .Threshold }}%" + "memoryThreshold": "🔴 Использование памяти {{ .Percent }}% превышает пороговое значение {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Сервер", + "category_clients": "👥 Клиенты", + "category_traffic": "🚦 Трафик", + "category_maintenance": "⚙️ Обслуживание", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 QR-коды:", + "qrLegendSubscription": "{{ .Code }}. Подписка", + "qrLegendJSON": "{{ .Code }}. JSON-подписка", + "qrLegendLink": "{{ .Code }}. Ссылка для приложения {{ .App }}", + "backupFailed": "Не удалось прочитать базу данных; бэкап не отправлен.", + "privateOnly": "🤖 Бот работает только в личных сообщениях. Открой его в личке." }, "buttons": { "closeKeyboard": "❌ Закрыть клавиатуру", @@ -2626,9 +2631,11 @@ "serverUsage": "💻 Состояние сервера", "getInbounds": "🔌 Входящие подключения", "depleteSoon": "⚠️ Скоро конец", - "clientUsage": "Статистика клиента", "onlines": "🟢 Онлайн", - "commands": "🖱️ Команды", + "catServer": "🖥 Сервер", + "catClients": "👥 Клиенты", + "catTraffic": "🚦 Трафик", + "catMaintenance": "⚙️ Обслуживание", "refresh": "🔄 Обновить", "clearIPs": "❌ Очистить IP", "removeTGUser": "❌ Удалить пользователя Telegram", @@ -2653,8 +2660,22 @@ "use_default": "🏷️ Использовать по умолчанию", "change_email": "⚙️📧 Email", "change_comment": "⚙️💬 Комментарий", - "ResetAllTraffics": "Сбросить весь трафик", - "SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика" + "ResetAllTraffics": "♻️ Сбросить весь трафик", + "SortedTrafficUsageReport": "📈 Отсортированный отчет об использовании трафика", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Рассылка", + "attachInbound": "Привязать инбаунд ({{ .Count }})", + "attachDone": "✅ Готово" }, "answers": { "broadcastStarted": "🚀 Рассылка запущена.", @@ -2682,6 +2703,36 @@ "chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}", "chooseInbound": "Выберите входящее подключение", "qrCodeForClient": "QR-код для клиента {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "истёк", + "fromFirstUse": "с первого использования" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index e4d49c0b3..b611fc10f 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -85,7 +85,6 @@ "uploaded": "Odoslané", "expiry": "Platnosť do", "totalQuota": "Celkový dátový limit", - "individualLinks": "Samostatné odkazy", "active": "Aktívne", "inactive": "Neaktívne", "unlimited": "Bez obmedzenia", @@ -1378,7 +1377,7 @@ "telegramAPIServer": "Server API Telegramu", "telegramAPIServerDesc": "Server API na komunikáciu s Telegramom. Prázdne pole použije predvolený server.", "telegramChatId": "ID konverzácie správcu", - "telegramChatIdDesc": "ID konverzácií správcov v Telegrame oddelené čiarkou. Získate ich od {'@'}userinfobot alebo príkazom '/id' v botovi.", + "telegramChatIdDesc": "ID chatu (chatov) správcu v Telegrame. (oddelené čiarkou)(získate ho tu {'@'}userinfobot) alebo (použite príkaz '/id' v bote). Na posielanie do témy fóra pripojte ':topicId', napr. -1001234567890:42", "telegramNotifyTime": "Čas upozornení", "telegramNotifyTimeDesc": "Ako často Telegram bot posiela pravidelné správy. Vyberte interval alebo možnosť Vlastné a zadajte výraz crontab.", "notifyTime": { @@ -1950,6 +1949,11 @@ "subIncyResolveDnsIp": "IP servera DoH", "subIncyResolveDnsIpDesc": "IP adresa servera DoH použitá na počiatočné pripojenie.", "subIncyGroupRouting": "Smerovanie", + "tgInlineMissing": "Inline režim je vypnutý", + "tgInlineMissingDesc": "Zoznamy v bote sa otvárajú cez inline vyhľadávanie. Zapnite ho v @BotFather príkazom /setinline, inak ťuknutie na tlačidlo vyhľadávania nič neurobí.", + "tgInlineRecheck": "Skontrolovať znova", + "tgPrivacyOn": "Group Privacy je zapnuté", + "tgPrivacyOnDesc": "Tento bot odpovedá len v súkromnom chate, takže skupiny zámerne ignoruje. Group Privacy určuje len to, či bot smie čítať správy v skupinách, čo nepoužíva.", "subHostSortMode": "Poradie uzlov v predplatnom", "subHostSortInbound": "Poradie inboundov (predvolené)", "subHostSortHosts": "Poradie hostiteľov", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Vlastná klávesnica bola zatvorená!", "noResult": "❗ Žiadny výsledok!", "noQuery": "❌ Dotaz sa nenašiel. Použite príkaz znova.", "wentWrong": "❌ Niečo sa nepodarilo!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Neznámy príkaz.", "pleaseChoose": "👇 Vyberte možnosť:\r\n", "help": "🤖 Vitajte! Tento bot zobrazuje údaje z webového panelu a umožňuje ich podľa potreby upravovať.\r\n\r\n", - "start": "👋 Dobrý deň, {{ .Firstname }}.\r\n", - "welcome": "🤖 Vitajte v botovi na správu {{ .Hostname }}.\r\n", - "status": "✅ Bot je v poriadku!", - "usage": "❗ Zadajte text na vyhľadanie!", "getID": "🆔 Vaše ID: {{ .ID }}", "helpAdminCommands": "Reštart jadra Xray:\r\n/restart\r\n\r\nVyhľadanie klienta podľa názvu:\r\n/usage [Email]\r\n\r\nVyhľadanie vstupov so štatistikami klientov:\r\n/inbound [Remark]\r\n\r\nID konverzácie Telegramu:\r\n/id", "helpClientCommands": "Štatistiky klienta vyhľadáte príkazom:\r\n\r\n/usage [Email]\r\n\r\nID konverzácie Telegramu:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Pri vykonávaní nastala chyba.\r\n\r\nChyba: {{ .Error }}.", "xrayNotRunning": "❗ Jadro Xray nebeží.", "startDesc": "Zobraziť hlavné menu", - "helpDesc": "Pomoc s botom", - "statusDesc": "Skontrolovať stav bota", - "idDesc": "Zobraziť vaše Telegram ID", - "usageDesc": "Zobraziť spotrebu klienta: /usage email", - "inboundDesc": "Vyhľadať vstupy: /inbound remark (správca)", - "restartDesc": "Reštartovať jadro Xray (správca)", - "broadcastDesc": "Poslať správu všetkým klientom (správca)", - "clearallDesc": "Vynulovať spotrebu všetkých klientov (správca)" + "statusOk": "✅ Bot je v poriadku!" }, "messages": { "cpuThreshold": "Záťaž CPU {{ .Percent }}% prekročila prah {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Tento pozývací odkaz je neplatný alebo už bol použitý.\r\n", "inviteRateLimited": "⏳ Priveľa pokusov o priradenie. Skúste to neskôr.\r\n", "inviteRateLimitedAdmin": "⚠️ Pokusy o priradenie zablokované pre {{ .User }} ({{ .ID }}): viac ako {{ .Limit }} pokusov za hodinu. Niekto môže hádať ID odberov.", - "inviteLink": "🔗 Pozývací odkaz pre {{ .Email }}:\r\n{{ .Link }}\r\n\r\nPrvý používateľ, ktorý ho otvorí, sa priradí k tomuto klientovi.\r\n", "exhaustedCount": "🚨 Počet s vyčerpaným limitom {{ .Type }}:\r\n", "onlinesCount": "🌐 Pripojení klienti: {{ .Count }}\r\n", "disabled": "🛑 Vypnutí: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Obnovené: {{ .Time }}\r\n\r\n", "yes": "✅ Áno", "no": "❌ Nie", - "received_email": "📧📥 Názov klienta bol aktualizovaný.", - "received_comment": "💬📥 Poznámka bola aktualizovaná.", "email_prompt": "📧 Predvolený názov klienta: {{ .ClientEmail }}\n\nZadajte názov klienta.", "comment_prompt": "💬 Predvolená poznámka: {{ .ClientComment }}\n\nZadajte poznámku.", + "tgid_prompt": "🆔 Predvolený používateľ TG: {{ .ClientTGID }}\n\nPošlite číselné ID používateľa, ktoré sa má priradiť k tomuto klientovi, alebo pošlite - / none na zrušenie.", "cancel": "❌ Akcia bola zrušená! \n\nKedykoľvek môžete začať znova príkazom /start. 🔄", "error_add_client": "⚠️ Chyba:\n\n {{ .error }}", - "using_default_value": "Dobre, ponechám predvolenú hodnotu. 😊", "incorrect_input": "Zadaná hodnota nie je platná.\nText musí byť súvislý, bez medzier.\nSprávne: aaaaaa\nNesprávne: aaa aaa 🚫", "broadcastAskText": "📤 Hromadná správa: pošlite obsah pre klientov — text, fotografiu, video, súbor alebo celý album.", "broadcastAlreadyRunning": "❗ Hromadné odosielanie už prebieha. Počkajte na jeho dokončenie.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "Server {{ .Name }} je NEDOSTUPNÝ", "eventNodeUp": "Server {{ .Name }} je DOSTUPNÝ", "eventLoginFallback": "Neúspešné prihlásenie z {{ .Source }}", - "memoryThreshold": "Využitie pamäte {{ .Percent }}% prekročilo prah {{ .Threshold }}%" + "memoryThreshold": "Využitie pamäte {{ .Percent }}% prekročilo prah {{ .Threshold }}%", + "inboundsCount": "🔌 Inboundy: {{ .Count }}\r\n", + "clientsCount": "👥 Klienti: {{ .Count }}\r\n", + "category_server": "🖥 Server", + "category_clients": "👥 Klienti", + "category_traffic": "🚦 Prenos", + "category_maintenance": "⚙️ Údržba", + "inlineHint": "\r\n🔍 Zoznam sa otvára cez vyhľadávanie: ťuknite na Hľadať a začnite písať.", + "subURL": "🔗 Predplatné: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Pošlite tento odkaz klientovi: k tomuto klientovi sa priradí prvý účet Telegramu, ktorý ho otvorí.", + "qrLegendHeader": "🔳 QR kódy:", + "qrLegendSubscription": "{{ .Code }}. Predplatné", + "qrLegendJSON": "{{ .Code }}. Predplatné JSON", + "qrLegendLink": "{{ .Code }}. Odkaz pre aplikáciu {{ .App }}", + "backupFailed": "Databázu sa nepodarilo prečítať; záloha nebola odoslaná.", + "privateOnly": "🤖 Tento bot funguje len v súkromnom chate. Otvorte ho, prosím, v priamej správe." }, "buttons": { "closeKeyboard": "❌ Zavrieť klávesnicu", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ Potvrdiť vyčistenie IP adries?", "confirmRemoveTGUser": "✅ Potvrdiť odobratie používateľa Telegramu?", "confirmToggle": "✅ Potvrdiť zapnutie/vypnutie používateľa?", - "dbBackup": "Získať zálohu databázy", - "serverUsage": "Využitie servera", - "getInbounds": "Zobraziť vstupy", - "depleteSoon": "Blíži sa vyčerpanie", - "clientUsage": "Zobraziť spotrebu", - "onlines": "Pripojení klienti", - "commands": "Príkazy", + "dbBackup": "💾 Záloha databázy", + "serverUsage": "📊 Využitie servera", + "getInbounds": "🔌 Inboundy", + "depleteSoon": "⚠️ Čoskoro vyčerpaní", + "onlines": "🟢 Klienti online", + "catServer": "🖥 Server", + "catClients": "👥 Klienti", + "catTraffic": "🚦 Prenos", + "catMaintenance": "⚙️ Údržba", "refresh": "🔄 Obnoviť", "clearIPs": "❌ Vyčistiť IP adresy", "removeTGUser": "❌ Odobrať používateľa Telegramu", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Potvrdiť: {{ .Num }}", "confirmNumberAdd": "✅ Potvrdiť pridanie: {{ .Num }}", "limitTraffic": "🚧 Dátový limit", - "getBanLogs": "Zobraziť záznamy blokovania", - "allClients": "Všetci klienti", - "addClient": "Pridať klienta", + "getBanLogs": "📄 Záznamy o blokovaní", + "allClients": "👥 Všetci klienti", + "addClient": "➕ Pridať klienta", "submitDisable": "Uložiť ako vypnuté ☑️", "submitEnable": "Uložiť ako zapnuté ✅", "use_default": "🏷️ Použiť predvolené", "change_email": "⚙️📧 Názov klienta", "change_comment": "⚙️💬 Poznámka", - "ResetAllTraffics": "Vynulovať všetku spotrebu", - "SortedTrafficUsageReport": "Zoradený prehľad spotreby" + "ResetAllTraffics": "♻️ Vynulovať všetok prenos", + "SortedTrafficUsageReport": "📈 Prehľad prenosu podľa využitia", + "restartXray": "♻️ Reštartovať Xray", + "back": "⬅️ Späť", + "hide": "🙈 Skryť", + "page": "Strana {{ .Page }} z {{ .Pages }}", + "searchInbounds": "🔍 Hľadať inbound…", + "searchClients": "🔍 Hľadať klienta…", + "inlineDisabled": "⚠️ Inline režim je vypnutý", + "clientCard": "⬅️ Karta klienta", + "links": "🔗 Odkazy", + "individualLinks": "🔗 Jednotlivé odkazy", + "qrCode": "🔳 QR kód", + "broadcast": "📢 Hromadná správa", + "attachInbound": "Pripojiť inbound ({{ .Count }})", + "attachDone": "✅ Hotovo" }, "answers": { "broadcastStarted": "🚀 Hromadné odosielanie sa začalo.", @@ -2682,6 +2703,36 @@ "chooseClient": "Vyberte klienta vstupu {{ .Inbound }}", "chooseInbound": "Vyberte vstup", "qrCodeForClient": "QR kód pre klienta {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Hlavná", + "status": "Server", + "inbounds": "Inboundy", + "clients": "Klienti", + "client": "Klient", + "links": "Odkazy", + "qr": "QR kód", + "wizard": "Nový klient", + "reports": "Prehľad", + "backup": "Záloha", + "onlines": "Online", + "deplete": "Dochádza", + "commands": "Príkazy", + "broadcast": "Hromadná správa" + }, + "inline": { + "noneTitle": "Nič sa nenašlo", + "noneDesc": "Na zapnutie zoznamu použite v @BotFather príkaz /setinline.", + "offTitle": "⚠️ Inline režim je pre tohto bota vypnutý", + "offBody": "Zoznamy sa prehliadajú v inline režime. Zapnite ho v @BotFather: /setinline a potom ťuknite na Skontrolovať znova.", + "recheck": "Skontrolovať znova", + "expired": "vypršalo", + "fromFirstUse": "od prvého použitia" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 opakované {{ .Count }}× · naposledy {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 357512fa8..75ba6fd26 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -85,7 +85,6 @@ "uploaded": "Yüklenen", "expiry": "Son Kullanma", "totalQuota": "Toplam Kota", - "individualLinks": "Bireysel Bağlantılar", "active": "Aktif", "inactive": "Pasif", "unlimited": "Sınırsız", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "DoH sunucu IP'si", "subIncyResolveDnsIpDesc": "Bootstrap için DoH sunucu IP'si.", "subIncyGroupRouting": "Yönlendirme", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "Grup gizliliği açık", + "tgPrivacyOnDesc": "Bu bot yalnızca özel sohbette yanıt verir, bu yüzden grupları bilerek yok sayar. Grup gizliliği yalnızca botun grup mesajlarını okuyup okuyamayacağını belirler; bot bunu kullanmaz.", "subHostSortMode": "Abonelik düğümü sıralaması", "subHostSortInbound": "Gelen bağlantı sırası (varsayılan)", "subHostSortHosts": "Ana bilgisayar sırası", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Klavye kapatıldı!", "noResult": "❗ Sonuç yok!", "noQuery": "❌ Sorgu bulunamadı! Lütfen komutu tekrar kullanın!", "wentWrong": "❌ Bir şeyler yanlış gitti!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Bilinmeyen komut.", "pleaseChoose": "👇 Lütfen seçin:\r\n", "help": "🤖 Bu bota hoş geldiniz! Web panelinden belirli verileri sunmak ve gerektiğinde değişiklik yapmanıza olanak tanımak için tasarlanmıştır.\r\n\r\n", - "start": "👋 Merhaba {{ .Firstname }}.\r\n", - "welcome": "🤖 {{ .Hostname }} yönetim botuna hoş geldiniz.\r\n", - "status": "✅ Bot çalışıyor!", - "usage": "❗ Lütfen aramak için bir metin sağlayın!", "getID": "🆔 Kimliğiniz: {{ .ID }}", "helpAdminCommands": "Xray Core'u yeniden başlatmak için:\r\n/restart\r\n\r\nBir kullanıcının istatistiklerini aramak için:\r\n/usage [E-posta]\r\n\r\nGelen bağlantılarnı aramak için (kullanıcı istatistikleri ile):\r\n/inbound [Açıklama]\r\n\r\nTelegram Sohbet Kimliği (Chat ID):\r\n/id", "helpClientCommands": "İstatistiklerinizi görmek için şu komutu kullanın:\r\n\r\n/usage [E-posta]\r\n\r\nTelegram Sohbet Kimliği:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ İşlem hatası.\r\n\r\nHata: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core çalışmıyor.", "startDesc": "Ana menüyü göster", - "helpDesc": "Bot yardımı", - "statusDesc": "Bot durumunu kontrol et", - "idDesc": "Telegram Kimliğinizi gösterir", - "usageDesc": "İstemci kullanımını göster: /usage e-posta", - "inboundDesc": "Gelenleri ara: /inbound ad (yönetici)", - "restartDesc": "Xray çekirdeğini yeniden başlat (yönetici)", - "broadcastDesc": "Tüm istemcilere mesaj gönder (yönetici)", - "clearallDesc": "Tüm istemcilerin trafiğini sıfırla (yönetici)" + "statusOk": "✅ Bot çalışıyor!" }, "messages": { "cpuThreshold": "CPU Yükü ({{ .Percent }}%), {{ .Threshold }}% eşiğini aşıyor", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Bu davet bağlantısı geçersiz veya daha önce kullanılmış.\r\n", "inviteRateLimited": "⏳ Çok fazla davet denemesi. Lütfen daha sonra tekrar deneyin.\r\n", "inviteRateLimitedAdmin": "⚠️ {{ .User }} ({{ .ID }}) için davet denemeleri engellendi: bir saatte {{ .Limit }} denemeden fazla. Birisi abonelik kimliklerini tahmin etmeye çalışıyor olabilir.", - "inviteLink": "🔗 {{ .Email }} için davet bağlantısı:\r\n{{ .Link }}\r\n\r\nBağlantıyı ilk açan kişi bu istemciye bağlanır.\r\n", "exhaustedCount": "🚨 Limiti Dolan {{ .Type }} sayısı:\r\n", "onlinesCount": "🌐 Çevrimiçi Kullanıcılar: {{ .Count }}\r\n", "disabled": "🛑 Devre Dışı: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Yenilendi: {{ .Time }}\r\n\r\n", "yes": "✅ Evet", "no": "❌ Hayır", - "received_email": "📧📥 E-posta güncellendi.", - "received_comment": "💬📥 Yorum güncellendi.", "email_prompt": "📧 Varsayılan E-posta: {{ .ClientEmail }}\n\nE-postanızı girin.", "comment_prompt": "💬 Varsayılan Yorum: {{ .ClientComment }}\n\nYorumunuzu girin.", + "tgid_prompt": "🆔 Varsayılan TG kullanıcısı: {{ .ClientTGID }}\n\nBu istemciye bağlamak için kullanıcının sayısal id'sini gönderin veya temizlemek için - / none gönderin.", "cancel": "❌ İşlem iptal edildi! \n\nİstediğiniz zaman /start ile yeniden başlayabilirsiniz. 🔄", "error_add_client": "⚠️ Hata:\n\n {{ .error }}", - "using_default_value": "Tamam, varsayılan değeri kullanacağım. 😊", "incorrect_input": "Geçersiz değer girdiniz.\nİfadeler arasında boşluk olmamalıdır.\nDoğru örnek: aaaaaa\nYanlış örnek: aaa aaa 🚫", "broadcastAskText": "📤 Toplu gönderim: istemcilerinize iletmek istediğiniz mesajı bana gönderin — metin, fotoğraf, video, dosya veya bir albüm.", "broadcastAlreadyRunning": "❗ Zaten devam eden bir toplu gönderim var, bitmesini bekleyin.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI", "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ", "eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız", - "memoryThreshold": "Bellek kullanımı {{ .Percent }}% eşiği {{ .Threshold }}% aşıyor" + "memoryThreshold": "Bellek kullanımı {{ .Percent }}% eşiği {{ .Threshold }}% aşıyor", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Sunucu", + "category_clients": "👥 İstemciler", + "category_traffic": "🚦 Trafik", + "category_maintenance": "⚙️ Bakım", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 QR kodları:", + "qrLegendSubscription": "{{ .Code }}. Abonelik", + "qrLegendJSON": "{{ .Code }}. JSON abonelik", + "qrLegendLink": "{{ .Code }}. Uygulama bağlantısı {{ .App }}", + "backupFailed": "Veritabanı okunamadı; yedek gönderilmedi.", + "privateOnly": "🤖 Bu bot yalnızca özel sohbette çalışır. Lütfen doğrudan mesajda açın." }, "buttons": { "closeKeyboard": "❌ Klavyeyi Kapat", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ IP'leri Temizlemeyi Onayla?", "confirmRemoveTGUser": "✅ Telegram Kullanıcısını Kaldırmayı Onayla?", "confirmToggle": "✅ Kullanıcıyı Etkinleştirme/Devre Dışı Bırakmayı Onayla?", - "dbBackup": "Veritabanı Yedeği Al", - "serverUsage": "Sunucu Kullanımı", - "getInbounds": "Gelen Bağlantılarnı Al", - "depleteSoon": "Kotası Dolmak Üzere", - "clientUsage": "Kullanıcı İstatistikleri", - "onlines": "Çevrimiçi Kullanıcılar", - "commands": "Komutlar", + "dbBackup": "💾 Veritabanı Yedeği Al", + "serverUsage": "📊 Sunucu Kullanımı", + "getInbounds": "🔌 Gelen Bağlantılarnı Al", + "depleteSoon": "⚠️ Kotası Dolmak Üzere", + "onlines": "🟢 Çevrimiçi Kullanıcılar", + "catServer": "🖥 Sunucu", + "catClients": "👥 İstemciler", + "catTraffic": "🚦 Trafik", + "catMaintenance": "⚙️ Bakım", "refresh": "🔄 Yenile", "clearIPs": "❌ IP'leri Temizle", "removeTGUser": "❌ Telegram Kullanıcısını Kaldır", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Onayla: {{ .Num }}", "confirmNumberAdd": "✅ Ekleme onayı: {{ .Num }}", "limitTraffic": "🚧 Trafik Sınırı", - "getBanLogs": "Yasak Günlüklerini Al", - "allClients": "Tüm Kullanıcılar", - "addClient": "Kullanıcı Ekle", + "getBanLogs": "📄 Yasak Günlüklerini Al", + "allClients": "👥 Tüm Kullanıcılar", + "addClient": "➕ Kullanıcı Ekle", "submitDisable": "Devre Dışı Olarak Gönder ☑️", "submitEnable": "Etkin Olarak Gönder ✅", "use_default": "🏷️ Varsayılanı Kullan", "change_email": "⚙️📧 E-posta", "change_comment": "⚙️💬 Yorum", - "ResetAllTraffics": "Tüm Trafikleri Sıfırla", - "SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu" + "ResetAllTraffics": "♻️ Tüm Trafikleri Sıfırla", + "SortedTrafficUsageReport": "📈 Sıralı Trafik Kullanım Raporu", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Yayın", + "attachInbound": "Inbound ekle ({{ .Count }})", + "attachDone": "✅ Tamam" }, "answers": { "broadcastStarted": "🚀 Toplu gönderim başladı.", @@ -2682,6 +2703,36 @@ "chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin", "chooseInbound": "Bir Gelen Bağlantı Seçin", "qrCodeForClient": "{{ .Email }} istemcisi için QR Kodu:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "süresi doldu", + "fromFirstUse": "ilk kullanımdan" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 2543ccd1f..3f6850ba3 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -85,7 +85,6 @@ "uploaded": "Відвантажено", "expiry": "Термін дії", "totalQuota": "Загальна квота", - "individualLinks": "Окремі посилання", "active": "Активна", "inactive": "Неактивна", "unlimited": "Безліміт", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "IP DoH-сервера", "subIncyResolveDnsIpDesc": "IP DoH-сервера для bootstrap.", "subIncyGroupRouting": "Маршрутизація", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "Режим приватності увімкнено", + "tgPrivacyOnDesc": "Бот працює лише в особистих повідомленнях, тож групи він ігнорує навмисно. Приватність у групах визначає лише те, чи може бот читати повідомлення групи, — а він цього не використовує.", "subHostSortMode": "Порядок вузлів підписки", "subHostSortInbound": "Порядок вхідних підключень (типово)", "subHostSortHosts": "Порядок хостів", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Клавіатуру закрито!", "noResult": "❗ Немає результату!", "noQuery": "❌ Запит не знайдено! Будь ласка, використовуйте команду ще раз!", "wentWrong": "❌ Щось пішло не так!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Невідома команда.", "pleaseChoose": "👇 Будь ласка, виберіть:\r\n", "help": "🤖 Ласкаво просимо до цього бота! Він розроблений, щоб надавати певні дані з веб-панелі та дозволяє вносити зміни за потреби.\r\n\r\n", - "start": "👋 Привіт {{ .Firstname }}.\r\n", - "welcome": "🤖 Ласкаво просимо до {{ .Hostname }} бота керування.\r\n", - "status": "✅ Бот в порядку!", - "usage": "❗ Введіть текст для пошуку!", "getID": "🆔 Ваш ідентифікатор: {{ .ID }}", "helpAdminCommands": "Для перезапуску Xray Core:\r\n/restart\r\n\r\nДля пошуку електронної пошти клієнта:\r\n/usage [Електронна пошта]\r\n\r\nДля пошуку вхідних (зі статистикою клієнта):\r\n/inbound [Примітка]\r\n\r\nID чату Telegram:\r\n/id", "helpClientCommands": "Для пошуку статистики використовуйте наступну команду:\r\n/usage [Електронна пошта]\r\n\r\nID чату Telegram:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Помилка в операції.\r\n\r\nПомилка: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core не запущений.", "startDesc": "Показати головне меню", - "helpDesc": "Довідка по боту", - "statusDesc": "Перевірити статус бота", - "idDesc": "Показати ваш Telegram ID", - "usageDesc": "Показати трафік клієнта: /usage email", - "inboundDesc": "Пошук вхідних: /inbound назва (адмін)", - "restartDesc": "Перезапустити ядро Xray (адмін)", - "broadcastDesc": "Масове розсилання всім клієнтам (адмін)", - "clearallDesc": "Скинути трафік усіх клієнтів (адмін)" + "statusOk": "✅ Бот працює!" }, "messages": { "cpuThreshold": "Навантаження ЦП {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Це посилання-запрошення недійсне або вже використане.\r\n", "inviteRateLimited": "⏳ Забагато спроб запрошення. Спробуйте пізніше.\r\n", "inviteRateLimitedAdmin": "⚠️ Спроби запрошення заблоковано для {{ .User }} ({{ .ID }}): понад {{ .Limit }} спроб за годину. Можливо, хтось підбирає ID підписок.", - "inviteLink": "🔗 Посилання-запрошення для {{ .Email }}:\r\n{{ .Link }}\r\n\r\nПерший, хто його відкриє, буде прив'язаний до цього клієнта.\r\n", "exhaustedCount": "🚨 Вичерпано кількість {{ .Type }} count:\r\n", "onlinesCount": "🌐 Онлайн-клієнти: {{ .Count }}\r\n", "disabled": "🛑 Вимкнено: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Оновлено: {{ .Time }}\r\n\r\n", "yes": "✅ Так", "no": "❌ Ні", - "received_email": "📧📥 Електронна пошта оновлена.", - "received_comment": "💬📥 Коментар оновлено.", "email_prompt": "📧 Стандартний email: {{ .ClientEmail }}\n\nВведіть ваш email.", "comment_prompt": "💬 Стандартний коментар: {{ .ClientComment }}\n\nВведіть ваш коментар.", + "tgid_prompt": "🆔 Стандартний TG-користувач: {{ .ClientTGID }}\n\nНадішліть числовий id користувача, щоб прив'язати до клієнта, або - / none щоб очистити.", "cancel": "❌ Процес скасовано! \n\nВи можете знову розпочати, використовуючи /start у будь-який час. 🔄", "error_add_client": "⚠️ Помилка:\n\n {{ .error }}", - "using_default_value": "Гаразд, залишу значення за замовчуванням. 😊", "incorrect_input": "Ваш ввід невірний.\nФрази повинні бути без пробілів.\nПравильний приклад: aaaaaa\nНеправильний приклад: aaa aaa 🚫", "broadcastAskText": "📤 Розсилка: надішліть повідомлення для ваших клієнтів — текст, фото, відео, файл або цілий альбом.", "broadcastAlreadyRunning": "❗ Розсилка вже триває, зачекайте її завершення.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ", "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ", "eventLoginFallback": "Невдала спроба входу з {{ .Source }}", - "memoryThreshold": "Використання пам'яті {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%" + "memoryThreshold": "Використання пам'яті {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Сервер", + "category_clients": "👥 Клієнти", + "category_traffic": "🚦 Трафік", + "category_maintenance": "⚙️ Обслуговування", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 QR-коди:", + "qrLegendSubscription": "{{ .Code }}. Підписка", + "qrLegendJSON": "{{ .Code }}. JSON-підписка", + "qrLegendLink": "{{ .Code }}. Посилання для застосунку {{ .App }}", + "backupFailed": "Не вдалося прочитати базу даних; резервну копію не надіслано.", + "privateOnly": "🤖 Бот працює лише в особистих повідомленнях. Відкрий його в особистому чаті." }, "buttons": { "closeKeyboard": "❌ Закрити клавіатуру", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ Підтвердити очищення IP-адрес?", "confirmRemoveTGUser": "✅ Підтвердити видалення користувача Telegram?", "confirmToggle": "✅ Підтвердити ввімкнути/вимкнути користувача?", - "dbBackup": "Отримати резервну копію БД", - "serverUsage": "Використання сервера", - "getInbounds": "Отримати вхідні", - "depleteSoon": "Скоро вичерпати", - "clientUsage": "Отримати використання", - "onlines": "Онлайн-клієнти", - "commands": "Команди", + "dbBackup": "💾 Отримати резервну копію БД", + "serverUsage": "📊 Використання сервера", + "getInbounds": "🔌 Отримати вхідні", + "depleteSoon": "⚠️ Скоро вичерпати", + "onlines": "🟢 Онлайн-клієнти", + "catServer": "🖥 Сервер", + "catClients": "👥 Клієнти", + "catTraffic": "🚦 Трафік", + "catMaintenance": "⚙️ Обслуговування", "refresh": "🔄 Оновити", "clearIPs": "❌ Очистити IP-адреси", "removeTGUser": "❌ Видалити користувача Telegram", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Підтвердити: {{ .Num }}", "confirmNumberAdd": "✅ Підтвердити додавання: {{ .Num }}", "limitTraffic": "🚧 Ліміт трафіку", - "getBanLogs": "Отримати журнали заборон", - "allClients": "Всі Клієнти", - "addClient": "Додати клієнта", + "getBanLogs": "📄 Отримати журнали заборон", + "allClients": "👥 Всі Клієнти", + "addClient": "➕ Додати клієнта", "submitDisable": "Надіслати як вимкнено ☑️", "submitEnable": "Надіслати як увімкнено ✅", "use_default": "🏷️ Використати типове", "change_email": "⚙️📧 Email", "change_comment": "⚙️💬 Коментар", - "ResetAllTraffics": "Скинути весь трафік", - "SortedTrafficUsageReport": "Відсортований звіт про використання трафіку" + "ResetAllTraffics": "♻️ Скинути весь трафік", + "SortedTrafficUsageReport": "📈 Відсортований звіт про використання трафіку", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Розсилка", + "attachInbound": "Прив’язати інбаунд ({{ .Count }})", + "attachDone": "✅ Готово" }, "answers": { "broadcastStarted": "🚀 Розсилку запущено.", @@ -2682,6 +2703,36 @@ "chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}", "chooseInbound": "Виберіть Вхідний", "qrCodeForClient": "QR-код для клієнта {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "минув", + "fromFirstUse": "з першого використання" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index d6efc0120..e84ac2bef 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -85,7 +85,6 @@ "uploaded": "Đã tải lên", "expiry": "Hết hạn", "totalQuota": "Tổng hạn mức", - "individualLinks": "Liên kết riêng lẻ", "active": "Hoạt động", "inactive": "Không hoạt động", "unlimited": "Không giới hạn", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "IP máy chủ DoH", "subIncyResolveDnsIpDesc": "IP máy chủ DoH để khởi tạo.", "subIncyGroupRouting": "Định tuyến", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "Quyền riêng tư nhóm đang bật", + "tgPrivacyOnDesc": "Bot này chỉ trả lời trong trò chuyện riêng, nên nhóm bị bỏ qua có chủ đích. Quyền riêng tư nhóm chỉ quyết định bot có được đọc tin nhắn nhóm hay không, và bot không dùng đến.", "subHostSortMode": "Thứ tự nút đăng ký", "subHostSortInbound": "Thứ tự kết nối vào (mặc định)", "subHostSortHosts": "Thứ tự máy chủ", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ Bàn phím đã đóng!", "noResult": "❗ Không có kết quả!", "noQuery": "❌ Không tìm thấy truy vấn! Vui lòng sử dụng lại lệnh!", "wentWrong": "❌ Đã xảy ra lỗi!", @@ -2508,10 +2511,6 @@ "unknown": "❗ Lệnh không rõ", "pleaseChoose": "👇 Vui lòng chọn:\r\n", "help": "🤖 Chào mừng bạn đến với bot này! Bot được thiết kế để cung cấp cho bạn dữ liệu cụ thể từ máy chủ và cho phép bạn thực hiện các thay đổi cần thiết.\r\n\r\n", - "start": "👋 Xin chào {{ .Firstname }}.\r\n", - "welcome": "🤖 Chào mừng đến với bot quản lý của {{ .Hostname }}.\r\n", - "status": "✅ Bot hoạt động bình thường!", - "usage": "❗ Vui lòng cung cấp văn bản để tìm kiếm!", "getID": "🆔 ID của bạn: {{ .ID }}", "helpAdminCommands": "Để khởi động lại Xray Core:\r\n/restart\r\n\r\nĐể tìm kiếm email của khách hàng:\r\n/usage [Email]\r\n\r\nĐể tìm kiếm các nhập (với số liệu thống kê của khách hàng):\r\n/inbound [Ghi chú]\r\n\r\nID Trò chuyện Telegram:\r\n/id", "helpClientCommands": "Để tìm kiếm thống kê, sử dụng lệnh sau:\r\n/usage [Email]\r\n\r\nID Trò chuyện Telegram:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ Lỗi trong quá trình hoạt động.\r\n\r\nLỗi: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core không chạy.", "startDesc": "Hiển thị menu chính", - "helpDesc": "Trợ giúp bot", - "statusDesc": "Kiểm tra trạng thái bot", - "idDesc": "Hiển thị ID Telegram của bạn", - "usageDesc": "Xem mức dùng của khách: /usage email", - "inboundDesc": "Tìm inbound: /inbound tên (quản trị)", - "restartDesc": "Khởi động lại lõi Xray (quản trị)", - "broadcastDesc": "Gửi tin nhắn tới tất cả khách hàng (quản trị)", - "clearallDesc": "Đặt lại lưu lượng mọi khách hàng (quản trị)" + "statusOk": "✅ Bot hoạt động bình thường!" }, "messages": { "cpuThreshold": "Sử dụng CPU {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ Liên kết mời này không hợp lệ hoặc đã được sử dụng.\r\n", "inviteRateLimited": "⏳ Quá nhiều lần thử lời mời. Vui lòng thử lại sau.\r\n", "inviteRateLimitedAdmin": "⚠️ Đã chặn các lần thử lời mời của {{ .User }} ({{ .ID }}): hơn {{ .Limit }} lần trong một giờ. Có thể ai đó đang đoán ID đăng ký.", - "inviteLink": "🔗 Liên kết mời cho {{ .Email }}:\r\n{{ .Link }}\r\n\r\nNgười đầu tiên mở nó sẽ được liên kết với client này.\r\n", "exhaustedCount": "🚨 Số lần cạn kiệt {{ .Type }}:\r\n", "onlinesCount": "🌐 Khách hàng trực tuyến: {{ .Count }}\r\n", "disabled": "🛑 Vô hiệu hóa: {{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 Đã cập nhật lần cuối vào: {{ .Time }}\r\n\r\n", "yes": "✅ Có", "no": "❌ Không", - "received_email": "📧📥 Email đã được cập nhật.", - "received_comment": "💬📥 Bình luận đã được cập nhật.", "email_prompt": "📧 Email mặc định: {{ .ClientEmail }}\n\nVui lòng nhập email của bạn.", "comment_prompt": "💬 Bình luận mặc định: {{ .ClientComment }}\n\nVui lòng nhập bình luận của bạn.", + "tgid_prompt": "🆔 Người dùng TG mặc định: {{ .ClientTGID }}\n\nGửi id số của người dùng để gắn vào client này, hoặc gửi - / none để xóa.", "cancel": "❌ Quá trình đã bị hủy! \n\nBạn có thể bắt đầu lại bất cứ lúc nào bằng cách nhập /start. 🔄", "error_add_client": "⚠️ Lỗi:\n\n {{ .error }}", - "using_default_value": "Được rồi, tôi sẽ sử dụng giá trị mặc định. 😊", "incorrect_input": "Dữ liệu bạn nhập không hợp lệ.\nCác chuỗi phải liền mạch và không có dấu cách.\nVí dụ đúng: aaaaaa\nVí dụ sai: aaa aaa 🚫", "broadcastAskText": "📤 Phát tin: gửi cho tôi tin nhắn để chuyển tới khách hàng của bạn — văn bản, ảnh, video, tệp hoặc cả bộ ảnh.", "broadcastAlreadyRunning": "❗ Một bản phát đang chạy, hãy đợi nó hoàn tất.", @@ -2610,7 +2599,23 @@ "eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG", "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG", "eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}", - "memoryThreshold": "Sử dụng bộ nhớ {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%" + "memoryThreshold": "Sử dụng bộ nhớ {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 Máy chủ", + "category_clients": "👥 Client", + "category_traffic": "🚦 Lưu lượng", + "category_maintenance": "⚙️ Bảo trì", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 Mã QR:", + "qrLegendSubscription": "{{ .Code }}. Gói đăng ký", + "qrLegendJSON": "{{ .Code }}. Gói đăng ký JSON", + "qrLegendLink": "{{ .Code }}. Liên kết ứng dụng {{ .App }}", + "backupFailed": "Không đọc được cơ sở dữ liệu; không có bản sao lưu nào được gửi.", + "privateOnly": "🤖 Bot này chỉ hoạt động trong trò chuyện riêng. Hãy mở nó trong tin nhắn trực tiếp." }, "buttons": { "closeKeyboard": "❌ Đóng Bàn Phím", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ Xác Nhận Xóa Các IP?", "confirmRemoveTGUser": "✅ Xác Nhận Xóa Người Dùng Telegram?", "confirmToggle": "✅ Xác nhận Bật/Tắt người dùng?", - "dbBackup": "Tải bản sao lưu cơ sở dữ liệu", - "serverUsage": "Sử Dụng Máy Chủ", - "getInbounds": "Lấy cổng vào", - "depleteSoon": "Depleted Soon", - "clientUsage": "Lấy Sử Dụng", - "onlines": "Khách hàng trực tuyến", - "commands": "Lệnh", + "dbBackup": "💾 Tải bản sao lưu cơ sở dữ liệu", + "serverUsage": "📊 Sử Dụng Máy Chủ", + "getInbounds": "🔌 Lấy cổng vào", + "depleteSoon": "⚠️ Depleted Soon", + "onlines": "🟢 Khách hàng trực tuyến", + "catServer": "🖥 Máy chủ", + "catClients": "👥 Client", + "catTraffic": "🚦 Lưu lượng", + "catMaintenance": "⚙️ Bảo trì", "refresh": "🔄 Cập Nhật", "clearIPs": "❌ Xóa IP", "removeTGUser": "❌ Xóa Người Dùng Telegram", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ Xác nhận: {{ .Num }}", "confirmNumberAdd": "✅ Xác nhận thêm: {{ .Num }}", "limitTraffic": "🚧 Giới hạn lưu lượng", - "getBanLogs": "Cấm nhật ký", - "allClients": "Tất cả Khách hàng", - "addClient": "Thêm Khách Hàng", + "getBanLogs": "📄 Cấm nhật ký", + "allClients": "👥 Tất cả Khách hàng", + "addClient": "➕ Thêm Khách Hàng", "submitDisable": "Gửi Dưới Dạng Vô Hiệu ☑️", "submitEnable": "Gửi Dưới Dạng Kích Hoạt ✅", "use_default": "🏷️ Sử Dụng Mặc Định", "change_email": "⚙️📧 Email", "change_comment": "⚙️💬 Bình Luận", - "ResetAllTraffics": "Đặt lại tất cả lưu lượng", - "SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp" + "ResetAllTraffics": "♻️ Đặt lại tất cả lưu lượng", + "SortedTrafficUsageReport": "📈 Báo cáo sử dụng lưu lượng đã sắp xếp", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 Phát tin", + "attachInbound": "Đính kèm inbound ({{ .Count }})", + "attachDone": "✅ Xong" }, "answers": { "broadcastStarted": "🚀 Đã bắt đầu phát tin.", @@ -2682,6 +2703,36 @@ "chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}", "chooseInbound": "Chọn một Inbound", "qrCodeForClient": "Mã QR cho khách hàng {{ .Email }}:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "hết hạn", + "fromFirstUse": "từ lần dùng đầu" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index bf8319138..101d3f470 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -85,7 +85,6 @@ "uploaded": "已上传", "expiry": "到期", "totalQuota": "总配额", - "individualLinks": "单独链接", "active": "启用", "inactive": "停用", "unlimited": "无限制", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "DoH 服务器 IP", "subIncyResolveDnsIpDesc": "用于引导的 DoH 服务器 IP。", "subIncyGroupRouting": "路由", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "群组隐私已开启", + "tgPrivacyOnDesc": "此机器人只在私聊中回复,因此会刻意忽略群组。群组隐私仅决定机器人能否读取群消息,而它并不使用该权限。", "subHostSortMode": "订阅节点排序", "subHostSortInbound": "入站顺序(默认)", "subHostSortHosts": "主机顺序", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ 自定义键盘已关闭!", "noResult": "❗ 没有结果!", "noQuery": "❌ 未找到查询!请再次使用该命令!", "wentWrong": "❌ 出了点问题!", @@ -2508,10 +2511,6 @@ "unknown": "❗ 未知命令", "pleaseChoose": "👇 请选择:\r\n", "help": "🤖 欢迎使用本机器人!它旨在为您提供来自服务器的特定数据,并允许您进行必要的修改。\r\n\r\n", - "start": "👋 你好,{{ .Firstname }}。\r\n", - "welcome": "🤖 欢迎来到 {{ .Hostname }} 管理机器人。\r\n", - "status": "✅ 机器人正常运行!", - "usage": "❗ 请输入要搜索的文本!", "getID": "🆔 您的 ID 为:{{ .ID }}", "helpAdminCommands": "要重新启动 Xray Core:\r\n/restart\r\n\r\n要搜索客户电子邮件:\r\n/usage [电子邮件]\r\n\r\n要搜索入站(带有客户统计数据):\r\n/inbound [备注]\r\n\r\nTelegram 聊天 ID:\r\n/id", "helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n/usage [电子邮件]\r\n\r\nTelegram 聊天 ID:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ 操作错误。\r\n\r\n错误: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core 未运行。", "startDesc": "显示主菜单", - "helpDesc": "机器人帮助", - "statusDesc": "检查机器人状态", - "idDesc": "显示您的 Telegram ID", - "usageDesc": "查看客户端用量:/usage 邮箱", - "inboundDesc": "搜索入站:/inbound 备注(管理员)", - "restartDesc": "重启 Xray 内核(管理员)", - "broadcastDesc": "向所有客户端群发消息(管理员)", - "clearallDesc": "重置所有客户端流量(管理员)" + "statusOk": "✅ 机器人运行正常!" }, "messages": { "cpuThreshold": "CPU 使用率为 {{ .Percent }}%,超过阈值 {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ 此邀请链接无效或已被使用。\r\n", "inviteRateLimited": "⏳ 邀请尝试次数过多,请稍后再试。\r\n", "inviteRateLimitedAdmin": "⚠️ 已阻止 {{ .User }}({{ .ID }})的邀请尝试:一小时内超过 {{ .Limit }} 次。可能有人在猜测订阅 ID。", - "inviteLink": "🔗 {{ .Email }} 的邀请链接:\r\n{{ .Link }}\r\n\r\n第一个打开它的人将绑定到此客户端。\r\n", "exhaustedCount": "🚨 耗尽的 {{ .Type }} 数量:\r\n", "onlinesCount": "🌐 在线客户:{{ .Count }}\r\n", "disabled": "🛑 禁用:{{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 刷新时间:{{ .Time }}\r\n\r\n", "yes": "✅ 是的", "no": "❌ 否", - "received_email": "📧📥 邮箱已更新。", - "received_comment": "💬📥 评论已更新。", "email_prompt": "📧 默认邮箱: {{ .ClientEmail }}\n\n请输入您的邮箱。", "comment_prompt": "💬 默认评论: {{ .ClientComment }}\n\n请输入您的评论。", + "tgid_prompt": "🆔 默认 TG 用户:{{ .ClientTGID }}\n\n发送用户的数字 id 以绑定到此客户端,或发送 - / none 以清除。", "cancel": "❌ 进程已取消!\n\n您可以随时使用 /start 重新开始。 🔄", "error_add_client": "⚠️ 错误:\n\n {{ .error }}", - "using_default_value": "好的,我会使用默认值。 😊", "incorrect_input": "您的输入无效。\n短语应连续输入,不能有空格。\n正确示例: aaaaaa\n错误示例: aaa aaa 🚫", "broadcastAskText": "📤 群发:请把要发给客户的消息发给我 — 文本、照片、视频、文件或整个相册都可以。", "broadcastAlreadyRunning": "❗ 已有群发正在进行,请等待其完成。", @@ -2610,7 +2599,23 @@ "eventNodeDown": "节点 {{ .Name }} 已离线", "eventNodeUp": "节点 {{ .Name }} 已上线", "eventLoginFallback": "来自 {{ .Source }} 的登录失败", - "memoryThreshold": "内存使用率 {{ .Percent }}% 超过阈值 {{ .Threshold }}%" + "memoryThreshold": "内存使用率 {{ .Percent }}% 超过阈值 {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 服务器", + "category_clients": "👥 客户端", + "category_traffic": "🚦 流量", + "category_maintenance": "⚙️ 维护", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 二维码:", + "qrLegendSubscription": "{{ .Code }}. 订阅", + "qrLegendJSON": "{{ .Code }}. JSON 订阅", + "qrLegendLink": "{{ .Code }}. 应用链接 {{ .App }}", + "backupFailed": "无法读取数据库,未发送备份。", + "privateOnly": "🤖 此机器人仅在私聊中工作。请在私信中打开它。" }, "buttons": { "closeKeyboard": "❌ 关闭键盘", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ 确认清除 IP?", "confirmRemoveTGUser": "✅ 确认移除 Telegram 用户?", "confirmToggle": "✅ 确认启用/禁用用户?", - "dbBackup": "获取数据库备份", - "serverUsage": "服务器使用情况", - "getInbounds": "获取入站信息", - "depleteSoon": "即将耗尽", - "clientUsage": "获取使用情况", - "onlines": "在线客户端", - "commands": "命令", + "dbBackup": "💾 获取数据库备份", + "serverUsage": "📊 服务器使用情况", + "getInbounds": "🔌 获取入站信息", + "depleteSoon": "⚠️ 即将耗尽", + "onlines": "🟢 在线客户端", + "catServer": "🖥 服务器", + "catClients": "👥 客户端", + "catTraffic": "🚦 流量", + "catMaintenance": "⚙️ 维护", "refresh": "🔄 刷新", "clearIPs": "❌ 清除 IP", "removeTGUser": "❌ 移除 Telegram 用户", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ 确认: {{ .Num }}", "confirmNumberAdd": "✅ 确认添加:{{ .Num }}", "limitTraffic": "🚧 流量限制", - "getBanLogs": "禁止日志", - "allClients": "所有客户", - "addClient": "添加客户", + "getBanLogs": "📄 禁止日志", + "allClients": "👥 所有客户", + "addClient": "➕ 添加客户", "submitDisable": "提交为禁用 ☑️", "submitEnable": "提交为启用 ✅", "use_default": "🏷️ 使用默认", "change_email": "⚙️📧 邮箱", "change_comment": "⚙️💬 评论", - "ResetAllTraffics": "重置所有流量", - "SortedTrafficUsageReport": "排序的流量使用报告" + "ResetAllTraffics": "♻️ 重置所有流量", + "SortedTrafficUsageReport": "📈 排序的流量使用报告", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 广播", + "attachInbound": "关联入站 ({{ .Count }})", + "attachDone": "✅ 完成" }, "answers": { "broadcastStarted": "🚀 群发已开始。", @@ -2682,6 +2703,36 @@ "chooseClient": "为入站 {{ .Inbound }} 选择一个客户", "chooseInbound": "选择一个入站", "qrCodeForClient": "客户端 {{ .Email }} 的二维码:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "已过期", + "fromFirstUse": "自首次使用" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index d3ee6df9d..a93b1a43a 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -85,7 +85,6 @@ "uploaded": "已上傳", "expiry": "到期", "totalQuota": "總配額", - "individualLinks": "個別連結", "active": "啟用", "inactive": "停用", "unlimited": "無限制", @@ -1830,6 +1829,11 @@ "subIncyResolveDnsIp": "DoH 伺服器 IP", "subIncyResolveDnsIpDesc": "用於引導的 DoH 伺服器 IP。", "subIncyGroupRouting": "路由", + "tgInlineMissing": "Inline mode is disabled", + "tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.", + "tgInlineRecheck": "Check again", + "tgPrivacyOn": "群組隱私已開啟", + "tgPrivacyOnDesc": "此機器人只在私聊中回覆,因此會刻意忽略群組。群組隱私僅決定機器人能否讀取群組訊息,而它並不使用該權限。", "subHostSortMode": "訂閱節點排序", "subHostSortInbound": "入站順序(預設)", "subHostSortHosts": "主機順序", @@ -2486,7 +2490,6 @@ } }, "tgbot": { - "keyboardClosed": "❌ 自定義鍵盤已關閉!", "noResult": "❗ 沒有結果!", "noQuery": "❌ 未找到查詢!請再次使用該命令!", "wentWrong": "❌ 出了點問題!", @@ -2508,10 +2511,6 @@ "unknown": "❗ 未知命令", "pleaseChoose": "👇 請選擇:\r\n", "help": "🤖 歡迎使用本機器人!它旨在為您提供來自伺服器的特定資料,並允許您進行必要的修改。\r\n\r\n", - "start": "👋 你好,{{ .Firstname }}。\r\n", - "welcome": "🤖 歡迎來到 {{ .Hostname }} 管理機器人。\r\n", - "status": "✅ 機器人正常執行!", - "usage": "❗ 請輸入要搜尋的文字!", "getID": "🆔 您的 ID 為:{{ .ID }}", "helpAdminCommands": "要重新啟動 Xray Core:\r\n/restart\r\n\r\n要搜尋客戶電子郵件:\r\n/usage [電子郵件]\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n/inbound [備註]\r\n\r\nTelegram 聊天 ID:\r\n/id", "helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n/usage [電子郵件]\r\n\r\nTelegram 聊天 ID:\r\n/id", @@ -2520,14 +2519,7 @@ "restartFailed": "❗ 操作錯誤。\r\n\r\n錯誤: {{ .Error }}.", "xrayNotRunning": "❗ Xray Core 未運行。", "startDesc": "顯示主選單", - "helpDesc": "機器人幫助", - "statusDesc": "檢查機器人狀態", - "idDesc": "顯示您的 Telegram ID", - "usageDesc": "查看客戶端用量:/usage 郵箱", - "inboundDesc": "搜尋入站:/inbound 備註(管理員)", - "restartDesc": "重啟 Xray 核心(管理員)", - "broadcastDesc": "向所有客戶端群發訊息(管理員)", - "clearallDesc": "重置所有客戶端流量(管理員)" + "statusOk": "✅ 機器人運作正常!" }, "messages": { "cpuThreshold": "CPU 使用率為 {{ .Percent }}%,超過閾值 {{ .Threshold }}%", @@ -2571,7 +2563,6 @@ "inviteInvalid": "❗ 此邀請連結無效或已被使用。\r\n", "inviteRateLimited": "⏳ 邀請嘗試次數過多,請稍後再試。\r\n", "inviteRateLimitedAdmin": "⚠️ 已封鎖 {{ .User }}({{ .ID }})的邀請嘗試:一小時內超過 {{ .Limit }} 次。可能有人在猜測訂閱 ID。", - "inviteLink": "🔗 {{ .Email }} 的邀請連結:\r\n{{ .Link }}\r\n\r\n第一個開啟它的人將綁定至此客戶端。\r\n", "exhaustedCount": "🚨 耗盡的 {{ .Type }} 數量:\r\n", "onlinesCount": "🌐 線上客戶:{{ .Count }}\r\n", "disabled": "🛑 禁用:{{ .Disabled }}\r\n", @@ -2580,13 +2571,11 @@ "refreshedOn": "\r\n📋🔄 重新整理時間:{{ .Time }}\r\n\r\n", "yes": "✅ 是的", "no": "❌ 否", - "received_email": "📧📥 電子郵件已更新。", - "received_comment": "💬📥 評論已更新。", "email_prompt": "📧 預設電子郵件: {{ .ClientEmail }}\n\n請輸入您的電子郵件。", "comment_prompt": "💬 預設評論: {{ .ClientComment }}\n\n請輸入您的評論。", + "tgid_prompt": "🆔 預設 TG 使用者:{{ .ClientTGID }}\n\n傳送使用者的數字 id 以綁定到此用戶端,或傳送 - / none 以清除。", "cancel": "❌ 程序已取消!\n\n您可以隨時使用 /start 重新開始。 🔄", "error_add_client": "⚠️ 錯誤:\n\n {{ .error }}", - "using_default_value": "好的,我會使用預設值。 😊", "incorrect_input": "您的輸入無效。\n短語應連續輸入,不能有空格。\n正確示例: aaaaaa\n錯誤示例: aaa aaa 🚫", "broadcastAskText": "📤 群發:請把要發給客戶的訊息傳給我 — 文字、照片、影片、檔案或整個相簿都可以。", "broadcastAlreadyRunning": "❗ 已有群發正在進行,請等待其完成。", @@ -2610,7 +2599,23 @@ "eventNodeDown": "節點 {{ .Name }} 已離線", "eventNodeUp": "節點 {{ .Name }} 已上線", "eventLoginFallback": "來自 {{ .Source }} 的登入失敗", - "memoryThreshold": "記憶體使用率 {{ .Percent }}% 超過閾值 {{ .Threshold }}%" + "memoryThreshold": "記憶體使用率 {{ .Percent }}% 超過閾值 {{ .Threshold }}%", + "inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n", + "clientsCount": "👥 Clients: {{ .Count }}\r\n", + "category_server": "🖥 伺服器", + "category_clients": "👥 用戶端", + "category_traffic": "🚦 流量", + "category_maintenance": "⚙️ 維護", + "inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.", + "subURL": "🔗 Subscription: {{ .URL }}\r\n", + "jsonURL": "🧩 JSON: {{ .URL }}\r\n", + "inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.", + "qrLegendHeader": "🔳 QR 碼:", + "qrLegendSubscription": "{{ .Code }}. 訂閱", + "qrLegendJSON": "{{ .Code }}. JSON 訂閱", + "qrLegendLink": "{{ .Code }}. 應用程式連結 {{ .App }}", + "backupFailed": "無法讀取資料庫,未傳送備份。", + "privateOnly": "🤖 此機器人僅在私聊中運作。請在私訊中開啟它。" }, "buttons": { "closeKeyboard": "❌ 關閉鍵盤", @@ -2622,13 +2627,15 @@ "confirmClearIps": "✅ 確認清除 IP?", "confirmRemoveTGUser": "✅ 確認移除 Telegram 使用者?", "confirmToggle": "✅ 確認啟用/禁用使用者?", - "dbBackup": "獲取資料庫備份", - "serverUsage": "伺服器使用情況", - "getInbounds": "獲取入站資訊", - "depleteSoon": "即將耗盡", - "clientUsage": "獲取使用情況", - "onlines": "線上客戶端", - "commands": "命令", + "dbBackup": "💾 獲取資料庫備份", + "serverUsage": "📊 伺服器使用情況", + "getInbounds": "🔌 獲取入站資訊", + "depleteSoon": "⚠️ 即將耗盡", + "onlines": "🟢 線上客戶端", + "catServer": "🖥 伺服器", + "catClients": "👥 用戶端", + "catTraffic": "🚦 流量", + "catMaintenance": "⚙️ 維護", "refresh": "🔄 重新整理", "clearIPs": "❌ 清除 IP", "removeTGUser": "❌ 移除 Telegram 使用者", @@ -2645,16 +2652,30 @@ "confirmNumber": "✅ 確認: {{ .Num }}", "confirmNumberAdd": "✅ 確認新增:{{ .Num }}", "limitTraffic": "🚧 流量限制", - "getBanLogs": "禁止日誌", - "allClients": "所有客戶", - "addClient": "新增客戶", + "getBanLogs": "📄 禁止日誌", + "allClients": "👥 所有客戶", + "addClient": "➕ 新增客戶", "submitDisable": "以停用方式送出 ☑️", "submitEnable": "以啟用方式送出 ✅", "use_default": "🏷️ 使用預設值", "change_email": "⚙️📧 電子郵件", "change_comment": "⚙️💬 評論", - "ResetAllTraffics": "重設所有流量", - "SortedTrafficUsageReport": "排序過的流量使用報告" + "ResetAllTraffics": "♻️ 重設所有流量", + "SortedTrafficUsageReport": "📈 排序過的流量使用報告", + "restartXray": "♻️ Restart Xray", + "back": "⬅️ Back", + "hide": "🙈 Hide", + "page": "Page {{ .Page }} of {{ .Pages }}", + "searchInbounds": "🔍 Find inbound…", + "searchClients": "🔍 Find client…", + "inlineDisabled": "⚠️ Inline mode is off", + "clientCard": "⬅️ Client card", + "links": "🔗 Links", + "individualLinks": "🔗 Individual links", + "qrCode": "🔳 QR code", + "broadcast": "📢 廣播", + "attachInbound": "關聯入站 ({{ .Count }})", + "attachDone": "✅ 完成" }, "answers": { "broadcastStarted": "🚀 群發已開始。", @@ -2682,6 +2703,36 @@ "chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶", "chooseInbound": "選擇一個入站", "qrCodeForClient": "客戶端 {{ .Email }} 的二維碼:" + }, + "screens": { + "panel": "Panel", + "main": "Main", + "status": "Server", + "inbounds": "Inbounds", + "clients": "Clients", + "client": "Client", + "links": "Links", + "qr": "QR code", + "wizard": "New client", + "reports": "Report", + "backup": "Backup", + "onlines": "Online", + "deplete": "Running out", + "commands": "Commands", + "broadcast": "Broadcast" + }, + "inline": { + "noneTitle": "Nothing found", + "noneDesc": "Use @BotFather /setinline to enable the list.", + "offTitle": "⚠️ Inline mode is disabled for this bot", + "offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.", + "recheck": "Check again", + "expired": "已過期", + "fromFirstUse": "自首次使用" + }, + "noValue": "—", + "notices": { + "repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}" } }, "discord": { diff --git a/internal/web/web.go b/internal/web/web.go index adf3c21d7..24d4d711d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -719,6 +719,19 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { return nil }) + controller.SetTgBotCapsFunc(func() (controller.TgBotCapabilities, error) { + caps, err := s.tgbotService.Capabilities() + if err != nil { + return controller.TgBotCapabilities{}, err + } + return controller.TgBotCapabilities{ + InlineEnabled: caps.InlineEnabled, + GroupPrivacy: caps.GroupPrivacy, + Username: caps.Username, + Running: caps.Running, + }, nil + }) + controller.SetReloadTgbotFunc(func() { enabled, err := s.settingService.GetTgbotEnabled() if err != nil || !enabled { diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index aa3cc3a99..7a5a9f526 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -120,6 +120,10 @@ func run(root, outDir string) error { Path: resolveRel(root, "internal/web/service/panel"), StructAllow: setOf("ApiTokenView", "PanelUpdateStatus", "Sponsor", "SponsorList"), }, + { + Path: resolveRel(root, "internal/web/controller"), + StructAllow: setOf("TgBotCapabilities"), + }, { Path: resolveRel(root, "internal/amneziawg"), StructAllow: setOf("ServerSettings"),