From ceca96571d2cb9c0c19401d85cb09e284ab2bc02 Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 17:16:32 +0000 Subject: [PATCH] P9(5): restore upstream Telegram bot (settings, backup, notifications) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user decision 2026-10-21 (reverses the 2026-10-20 drop-Telegram call): port the upstream 3x-ui v3.7.0-generation Telegram bot wholesale instead of rewriting a minimal set, adapted to this fork. - new internal/web/service/tgbot package (telego): bot lifecycle, commands, reports, callback router, send helpers; token validation via telego; custom API server URL passes the fork's SSRF sanitizer (loopback/private bases fall back to the default endpoint, logged) - settings: full upstream key set (tgBotEnable/Token/Proxy/APIServer/ ChatId, tgRunTime, tgBotBackup, tgCpu/tgMemory, tgLang, tgEnabledEvents) with getters/setters; settings page gains a Telegram tab + sidebar entry; token shown masked, clearTgBotToken honored - wiring (web.go): stats-notify cron registered from tgRunTime when the bot is enabled; CheckHashStorage job; CPU/memory alarm jobs gated on tgEnabledEvents + thresholds; login notifications via event bus; POST /panel/api/setting/testTgBot and /panel/api/backuptotgbot - lite adaptation: StatsNotifyJob no longer gates on a local Xray core (this panel never runs one) so the periodic report and the scheduled DB backup (sendDocument) can actually fire - translations: upstream TG keys restored across all locales End-to-end smoke (real binary + fake Telegram API over HTTPS at a public-looking address, browser form wire format): bot starts on settings change (getMe), testTgBot delivers, panel restart re-arms the bot, tgRunTime='@every 1m' cron really fires sendDocument carrying the panel SQLite DB (magic verified) plus the report sendMessage, login notification delivered, dead-endpoint failure is logged not silent. 13/13 PASS — see P9-REPORT.md. --- frontend/src/generated/examples.ts | 23 + frontend/src/generated/schemas.ts | 100 ++ frontend/src/generated/types.ts | 23 + frontend/src/generated/zod.ts | 23 + frontend/src/layouts/AppSidebar.tsx | 6 + frontend/src/pages/settings/SecretInput.tsx | 45 + frontend/src/pages/settings/SettingsPage.tsx | 4 + frontend/src/pages/settings/TelegramTab.tsx | 337 ++++ go.mod | 6 + go.sum | 12 + internal/web/controller/api.go | 8 + internal/web/controller/index.go | 23 + internal/web/controller/setting.go | 47 +- internal/web/entity/entity.go | 13 + internal/web/job/check_hash_storage.go | 22 + internal/web/job/stats_notify_job.go | 36 + internal/web/locale/locale.go | 29 +- .../web/service/inbound_tgbot_lookup_test.go | 67 + internal/web/service/inbound_traffic.go | 69 + internal/web/service/setting.go | 122 +- internal/web/service/setting_clash_test.go | 8 +- .../web/service/setting_sub_identity_test.go | 4 +- internal/web/service/tgbot/numeric_input.go | 17 + .../web/service/tgbot/numeric_input_test.go | 81 + internal/web/service/tgbot/tgbot.go | 537 ++++++ .../service/tgbot/tgbot_callback_auth_test.go | 40 + internal/web/service/tgbot/tgbot_client.go | 782 +++++++++ .../service/tgbot/tgbot_delete_after_test.go | 18 + internal/web/service/tgbot/tgbot_event.go | 162 ++ internal/web/service/tgbot/tgbot_inbound.go | 306 ++++ internal/web/service/tgbot/tgbot_report.go | 509 ++++++ internal/web/service/tgbot/tgbot_router.go | 1492 +++++++++++++++++ internal/web/service/tgbot/tgbot_send.go | 296 ++++ internal/web/service/tgbot/tgbot_send_test.go | 35 + internal/web/service/tgbot/tgbot_test.go | 125 ++ internal/web/translation/ar-EG.json | 219 ++- internal/web/translation/en-US.json | 219 ++- internal/web/translation/es-ES.json | 219 ++- internal/web/translation/fa-IR.json | 219 ++- internal/web/translation/id-ID.json | 219 ++- internal/web/translation/ja-JP.json | 219 ++- internal/web/translation/pt-BR.json | 219 ++- internal/web/translation/ru-RU.json | 219 ++- internal/web/translation/tr-TR.json | 219 ++- internal/web/translation/uk-UA.json | 219 ++- internal/web/translation/vi-VN.json | 219 ++- internal/web/translation/zh-CN.json | 219 ++- internal/web/translation/zh-TW.json | 219 ++- internal/web/web.go | 127 +- 49 files changed, 8351 insertions(+), 50 deletions(-) create mode 100644 frontend/src/pages/settings/SecretInput.tsx create mode 100644 frontend/src/pages/settings/TelegramTab.tsx create mode 100644 internal/web/job/check_hash_storage.go create mode 100644 internal/web/job/stats_notify_job.go create mode 100644 internal/web/service/inbound_tgbot_lookup_test.go create mode 100644 internal/web/service/tgbot/numeric_input.go create mode 100644 internal/web/service/tgbot/numeric_input_test.go create mode 100644 internal/web/service/tgbot/tgbot.go create mode 100644 internal/web/service/tgbot/tgbot_callback_auth_test.go create mode 100644 internal/web/service/tgbot/tgbot_client.go create mode 100644 internal/web/service/tgbot/tgbot_delete_after_test.go create mode 100644 internal/web/service/tgbot/tgbot_event.go create mode 100644 internal/web/service/tgbot/tgbot_inbound.go create mode 100644 internal/web/service/tgbot/tgbot_report.go create mode 100644 internal/web/service/tgbot/tgbot_router.go create mode 100644 internal/web/service/tgbot/tgbot_send.go create mode 100644 internal/web/service/tgbot/tgbot_send_test.go create mode 100644 internal/web/service/tgbot/tgbot_test.go diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 8e98e71..06ec29d 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -41,6 +41,17 @@ export const EXAMPLES: Record = { "subTitle": "", "subURI": "", "subUpdates": 0, + "tgBotAPIServer": "", + "tgBotBackup": false, + "tgBotChatId": "", + "tgBotEnable": false, + "tgBotProxy": "", + "tgBotToken": "", + "tgCpu": 0, + "tgEnabledEvents": "", + "tgLang": "", + "tgMemory": 0, + "tgRunTime": "", "timeLocation": "", "trafficDiff": 0, "trustedProxyCIDRs": "", @@ -57,6 +68,7 @@ export const EXAMPLES: Record = { "expireDiff": 0, "hasApiToken": false, "hasNordSecret": false, + "hasTgBotToken": false, "hasWarpSecret": false, "ipLimitAllowlist": "", "outboundDownThreshold": 1, @@ -96,6 +108,17 @@ export const EXAMPLES: Record = { "subTitle": "", "subURI": "", "subUpdates": 0, + "tgBotAPIServer": "", + "tgBotBackup": false, + "tgBotChatId": "", + "tgBotEnable": false, + "tgBotProxy": "", + "tgBotToken": "", + "tgCpu": 0, + "tgEnabledEvents": "", + "tgLang": "", + "tgMemory": 0, + "tgRunTime": "", "timeLocation": "", "trafficDiff": 0, "trustedProxyCIDRs": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 825fea4..a78fc49 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -134,6 +134,43 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "tgBotAPIServer": { + "type": "string" + }, + "tgBotBackup": { + "type": "boolean" + }, + "tgBotChatId": { + "type": "string" + }, + "tgBotEnable": { + "type": "boolean" + }, + "tgBotProxy": { + "type": "string" + }, + "tgBotToken": { + "type": "string" + }, + "tgCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "tgEnabledEvents": { + "type": "string" + }, + "tgLang": { + "type": "string" + }, + "tgMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "tgRunTime": { + "type": "string" + }, "timeLocation": { "type": "string" }, @@ -211,6 +248,17 @@ export const SCHEMAS: Record = { "subTitle", "subURI", "subUpdates", + "tgBotAPIServer", + "tgBotBackup", + "tgBotChatId", + "tgBotEnable", + "tgBotProxy", + "tgBotToken", + "tgCpu", + "tgEnabledEvents", + "tgLang", + "tgMemory", + "tgRunTime", "timeLocation", "trafficDiff", "trustedProxyCIDRs", @@ -239,6 +287,9 @@ export const SCHEMAS: Record = { "hasNordSecret": { "type": "boolean" }, + "hasTgBotToken": { + "type": "boolean" + }, "hasWarpSecret": { "type": "boolean" }, @@ -367,6 +418,43 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "tgBotAPIServer": { + "type": "string" + }, + "tgBotBackup": { + "type": "boolean" + }, + "tgBotChatId": { + "type": "string" + }, + "tgBotEnable": { + "type": "boolean" + }, + "tgBotProxy": { + "type": "string" + }, + "tgBotToken": { + "type": "string" + }, + "tgCpu": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "tgEnabledEvents": { + "type": "string" + }, + "tgLang": { + "type": "string" + }, + "tgMemory": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "tgRunTime": { + "type": "string" + }, "timeLocation": { "type": "string" }, @@ -408,6 +496,7 @@ export const SCHEMAS: Record = { "expireDiff", "hasApiToken", "hasNordSecret", + "hasTgBotToken", "hasWarpSecret", "ipLimitAllowlist", "outboundDownThreshold", @@ -447,6 +536,17 @@ export const SCHEMAS: Record = { "subTitle", "subURI", "subUpdates", + "tgBotAPIServer", + "tgBotBackup", + "tgBotChatId", + "tgBotEnable", + "tgBotProxy", + "tgBotToken", + "tgCpu", + "tgEnabledEvents", + "tgLang", + "tgMemory", + "tgRunTime", "timeLocation", "trafficDiff", "trustedProxyCIDRs", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 2077527..de8b47d 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -50,6 +50,17 @@ export interface AllSetting { subTitle: string; subURI: string; subUpdates: number; + tgBotAPIServer: string; + tgBotBackup: boolean; + tgBotChatId: string; + tgBotEnable: boolean; + tgBotProxy: string; + tgBotToken: string; + tgCpu: number; + tgEnabledEvents: string; + tgLang: string; + tgMemory: number; + tgRunTime: string; timeLocation: string; trafficDiff: number; trustedProxyCIDRs: string; @@ -67,6 +78,7 @@ export interface AllSettingView { expireDiff: number; hasApiToken: boolean; hasNordSecret: boolean; + hasTgBotToken: boolean; hasWarpSecret: boolean; ipLimitAllowlist: string; outboundDownThreshold: number; @@ -106,6 +118,17 @@ export interface AllSettingView { subTitle: string; subURI: string; subUpdates: number; + tgBotAPIServer: string; + tgBotBackup: boolean; + tgBotChatId: string; + tgBotEnable: boolean; + tgBotProxy: string; + tgBotToken: string; + tgCpu: number; + tgEnabledEvents: string; + tgLang: string; + tgMemory: number; + tgRunTime: string; timeLocation: string; trafficDiff: number; trustedProxyCIDRs: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index fbbf24c..ba6acd0 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -68,6 +68,17 @@ export const AllSettingSchema = z.object({ subTitle: z.string(), subURI: z.string(), subUpdates: z.number().int().min(0).max(525600), + tgBotAPIServer: z.string(), + tgBotBackup: z.boolean(), + tgBotChatId: z.string(), + tgBotEnable: z.boolean(), + tgBotProxy: z.string(), + tgBotToken: z.string(), + tgCpu: z.number().int().min(0).max(100), + tgEnabledEvents: z.string(), + tgLang: z.string(), + tgMemory: z.number().int().min(0).max(100), + tgRunTime: z.string(), timeLocation: z.string(), trafficDiff: z.number().int().min(0).max(100), trustedProxyCIDRs: z.string(), @@ -86,6 +97,7 @@ export const AllSettingViewSchema = z.object({ expireDiff: z.number().int().min(0), hasApiToken: z.boolean(), hasNordSecret: z.boolean(), + hasTgBotToken: z.boolean(), hasWarpSecret: z.boolean(), ipLimitAllowlist: z.string(), outboundDownThreshold: z.number().int().min(1).max(100), @@ -125,6 +137,17 @@ export const AllSettingViewSchema = z.object({ subTitle: z.string(), subURI: z.string(), subUpdates: z.number().int().min(0).max(525600), + tgBotAPIServer: z.string(), + tgBotBackup: z.boolean(), + tgBotChatId: z.string(), + tgBotEnable: z.boolean(), + tgBotProxy: z.string(), + tgBotToken: z.string(), + tgCpu: z.number().int().min(0).max(100), + tgEnabledEvents: z.string(), + tgLang: z.string(), + tgMemory: z.number().int().min(0).max(100), + tgRunTime: z.string(), timeLocation: z.string(), trafficDiff: z.number().int().min(0).max(100), trustedProxyCIDRs: z.string(), diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 37bb182..0861a4a 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -19,6 +19,7 @@ import { ImportOutlined, LogoutOutlined, MenuOutlined, + MessageOutlined, MoonFilled, MoonOutlined, PushpinFilled, @@ -240,6 +241,11 @@ export default function AppSidebar() { icon: , label: t('pages.settings.securitySettings'), }, + { + key: '/settings#telegram', + icon: , + label: t('pages.settings.TGBotSettings'), + }, { key: '/settings#subscription', icon: , diff --git a/frontend/src/pages/settings/SecretInput.tsx b/frontend/src/pages/settings/SecretInput.tsx new file mode 100644 index 0000000..2b5a626 --- /dev/null +++ b/frontend/src/pages/settings/SecretInput.tsx @@ -0,0 +1,45 @@ +import { Button, Input, Space } from 'antd'; +import { useTranslation } from 'react-i18next'; + +interface SecretInputProps { + value: string; + configured: boolean; + clearArmed: boolean; + placeholder: string; + onChange: (value: string) => void; + onClearArmedChange: (armed: boolean) => void; +} + +export default function SecretInput({ + value, + configured, + clearArmed, + placeholder, + onChange, + onClearArmedChange, +}: SecretInputProps) { + const { t } = useTranslation(); + return ( + + { + onChange(e.target.value); + if (clearArmed) onClearArmedChange(false); + }} + /> + {configured && ( + + )} + + ); +} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 66dae2e..9db973d 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -28,6 +28,7 @@ import SecurityTab from './SecurityTab'; import SubscriptionGeneralTab from './SubscriptionGeneralTab'; import SubscriptionFormatsTab from './SubscriptionFormatsTab'; import SubscriptionBalancersTab from './SubscriptionBalancersTab'; +import TelegramTab from './TelegramTab'; import './SettingsPage.css'; interface ApiMsg { @@ -37,6 +38,7 @@ interface ApiMsg { const tabSlugs = [ 'general', 'security', + 'telegram', 'subscription', 'subscription-formats', 'subscription-balancers', @@ -195,6 +197,8 @@ export default function SettingsPage() { switch (activeSlug) { case 'security': return ; + case 'telegram': + return ; case 'subscription': return ; case 'subscription-formats': diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx new file mode 100644 index 0000000..66445f9 --- /dev/null +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -0,0 +1,337 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd'; +import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; +import { LanguageManager } from '@/utils'; +import { HttpUtil } from '@/utils'; +import { onNumber } from '@/utils/onNumber'; +import type { AllSetting } from '@/models/setting'; +import { SettingListItem } from '@/components/ui'; +import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { catTabLabel } from './catTabLabel'; +import SecretInput from './SecretInput'; + +interface TelegramTabProps { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +// The notification schedule is fed straight to robfig/cron's AddJob (see +// web.go startTask), which accepts @every , the @hourly/@daily/... +// macros, and full crontab expressions. This builder covers the common cases +// with dropdowns so users don't have to memorise the syntax, while "Custom" +// preserves the raw crontab escape hatch. +type Unit = 's' | 'm' | 'h'; +type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly'; +type Mode = 'every' | Macro | 'custom'; +const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly']; +const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i; + +interface RunTime { + mode: Mode; + num: number; + unit: Unit; + custom: string; +} + +function parseRunTime(raw: string): RunTime { + const v = (raw ?? '').trim(); + const m = v.match(EVERY_RE); + if (m) { + return { + mode: 'every', + num: Math.max(1, Number(m[1]) || 1), + unit: m[2].toLowerCase() as Unit, + custom: '', + }; + } + if ((MACROS as string[]).includes(v)) { + return { mode: v as Macro, num: 1, unit: 'h', custom: '' }; + } + return { mode: 'custom', num: 1, unit: 'h', custom: v }; +} + +function composeRunTime(s: RunTime): string { + if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`; + if (s.mode === 'custom') return s.custom; + return s.mode; +} + +// The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so +// crontab expressions are 6-field: "second minute hour day month weekday". When +// the user drops into Custom we seed the box with the crontab equivalent of the +// current selection rather than a bare @macro, so they get a real expression to +// edit (and one that the 6-field parser accepts). +function toCrontab(s: RunTime): string { + switch (s.mode) { + case '@hourly': + return '0 0 * * * *'; + case '@daily': + return '0 0 0 * * *'; + case '@weekly': + return '0 0 0 * * 0'; + case '@monthly': + return '0 0 0 1 * *'; + case 'every': { + const n = Math.max(1, s.num || 1); + if (s.unit === 's') return `*/${n} * * * * *`; + if (s.unit === 'm') return `0 */${n} * * * *`; + return `0 0 */${n} * * *`; + } + default: + return s.custom; + } +} + +function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const { t } = useTranslation(); + // Init once: the Settings tabs only mount after settings are fetched, so the + // incoming value is already the persisted one. + const [state, setState] = useState(() => parseRunTime(value)); + + function update(patch: Partial) { + const next = { ...state, ...patch }; + setState(next); + onChange(composeRunTime(next)); + } + + function onModeChange(mode: Mode) { + // Seed Custom with the crontab equivalent of the current selection so the + // box starts from a real expression (e.g. "0 0 0 * * *", not "@daily"). + if (mode === 'custom' && !state.custom.trim()) { + update({ mode, custom: toCrontab(state) }); + } else { + update({ mode }); + } + } + + const modeOptions = [ + { value: 'every', label: t('pages.settings.notifyTime.every') }, + { value: '@hourly', label: t('pages.settings.notifyTime.hourly') }, + { value: '@daily', label: t('pages.settings.notifyTime.daily') }, + { value: '@weekly', label: t('pages.settings.notifyTime.weekly') }, + { value: '@monthly', label: t('pages.settings.notifyTime.monthly') }, + { value: 'custom', label: t('pages.settings.notifyTime.custom') }, + ]; + const unitOptions = [ + { value: 's', label: t('pages.settings.notifyTime.seconds') }, + { value: 'm', label: t('pages.settings.notifyTime.minutes') }, + { value: 'h', label: t('pages.settings.notifyTime.hours') }, + ]; + + return ( + + + style={{ width: '100%' }} + value={state.mode} + options={modeOptions} + onChange={onModeChange} + aria-label={t('pages.settings.telegramNotifyTime')} + /> + {state.mode === 'every' && ( + + update({ num: Math.max(1, v) }))} + aria-label={t('pages.settings.notifyTime.interval')} + /> + + style={{ width: '50%' }} + value={state.unit} + options={unitOptions} + onChange={(unit) => update({ unit })} + aria-label={t('pages.settings.notifyTime.unit')} + /> + + )} + {state.mode === 'custom' && ( + update({ custom: e.target.value })} + aria-label={t('pages.settings.notifyTime.custom')} + /> + )} + + ); +} + +export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) { + const { t } = useTranslation(); + const { isMobile } = useMediaQuery(); + const [testLoading, setTestLoading] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null); + + async function handleTestTgBot() { + setTestLoading(true); + setTestResult(null); + try { + const res = (await HttpUtil.post('/panel/api/setting/testTgBot')) as { + success?: boolean; + msg?: string; + }; + setTestResult({ success: !!res.success, msg: res.msg || '' }); + } catch (e: unknown) { + setTestResult({ + success: false, + msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'), + }); + } finally { + setTestLoading(false); + } + } + + const langOptions = useMemo( + () => + LanguageManager.supportedLanguages.map( + (l: { value: string; name: string; icon: string }) => ({ + value: l.value, + label: ( + <> + + {l.icon} + +   {l.name} + + ), + }), + ), + [], + ); + + return ( + , t('pages.settings.panelSettings'), isMobile), + children: ( + <> + + updateSetting({ tgBotEnable: v })} + /> + + + + updateSetting({ tgBotToken: v })} + onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })} + /> + + + + updateSetting({ tgBotChatId: e.target.value })} + /> + + + + updateSetting({ tgBotAPIServer: e.target.value })} + /> + + + + + {testResult && ( + setTestResult(null) }} + /> + )} + + + ), + }, + { + key: '2', + label: catTabLabel(, t('pages.settings.notifications'), isMobile), + children: ( + <> + + updateSetting({ tgRunTime: v })} + /> + + + updateSetting({ tgBotBackup: v })} + /> + + + + + + + ), + }, + ]} + /> + ); +} diff --git a/go.mod b/go.mod index 893e3bf..6a3b54e 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,14 @@ require ( github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.19.2 github.com/mattn/go-sqlite3 v1.14.50 + github.com/mymmrac/telego v1.11.2 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.26.7 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/valyala/fasthttp v1.73.0 go.uber.org/atomic v1.11.0 golang.org/x/crypto v0.55.0 golang.org/x/net v0.58.0 @@ -51,6 +54,7 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect + github.com/grbit/go-json v0.11.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.10.0 // indirect @@ -73,6 +77,8 @@ require ( github.com/tklauser/numcpus v0.12.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.2 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/go.sum b/go.sum index 5cfbc88..0153259 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= +github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -100,6 +102,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mymmrac/telego v1.11.2 h1:f/CCSKHsXEHCEQPixOj6/l8WEns9YlXKvh9zi/Iinww= +github.com/mymmrac/telego v1.11.2/go.mod h1:wo7Y5Ux7xUZs04xzmP0SUFGvWVDJDcMDV9aJ1jwqFl0= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= @@ -121,6 +125,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -141,6 +147,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= +github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 0e3415a..32ca73b 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -7,6 +7,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" "github.com/mhsanaei/3x-ui/v3/internal/web/session" "github.com/gin-gonic/gin" @@ -24,6 +25,7 @@ type APIController struct { xraySettingController *XraySettingController userService panel.UserService apiTokenService panel.ApiTokenService + Tgbot tgbot.Tgbot } // NewAPIController creates a new APIController instance and initializes its routes. @@ -208,4 +210,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { NewSubBalancerController(api) // Extra routes + api.POST("/backuptotgbot", a.BackuptoTgbot) +} + +// BackuptoTgbot sends a backup of the panel data to Telegram bot admins. +func (a *APIController) BackuptoTgbot(c *gin.Context) { + a.Tgbot.SendBackupToAdmins() } diff --git a/internal/web/controller/index.go b/internal/web/controller/index.go index b49e7d7..f6c1cf7 100644 --- a/internal/web/controller/index.go +++ b/internal/web/controller/index.go @@ -9,6 +9,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" "github.com/mhsanaei/3x-ui/v3/internal/web/session" "github.com/gin-gonic/gin" @@ -26,6 +27,7 @@ type IndexController struct { settingService service.SettingService userService panel.UserService + tgbot tgbot.Tgbot } // NewIndexController creates a new IndexController and initializes its routes. @@ -73,9 +75,17 @@ func (a *IndexController) login(c *gin.Context) { remoteIP := getRemoteIp(c) safeUser := template.HTMLEscapeString(form.Username) + timeStr := time.Now().Format("2006-01-02 15:04:05") if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok { reason := "too many failed attempts" logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339)) + a.tgbot.UserLoginNotify(tgbot.LoginAttempt{ + Username: safeUser, + IP: remoteIP, + Time: timeStr, + Status: tgbot.LoginFail, + Reason: reason, + }) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) return } @@ -89,12 +99,25 @@ func (a *IndexController) login(c *gin.Context) { } else { logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason) } + a.tgbot.UserLoginNotify(tgbot.LoginAttempt{ + Username: safeUser, + IP: remoteIP, + Time: timeStr, + Status: tgbot.LoginFail, + Reason: reason, + }) pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) return } defaultLoginLimiter.registerSuccess(remoteIP, form.Username) logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP) + a.tgbot.UserLoginNotify(tgbot.LoginAttempt{ + Username: safeUser, + IP: remoteIP, + Time: timeStr, + Status: tgbot.LoginSuccess, + }) if err := session.SetLoginUser(c, user); err != nil { logger.Warning("Unable to save session:", err) diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index b5fbba8..50c4600 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -62,6 +62,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/defaultSettings", a.getDefaultSettings) g.POST("/factoryDefaults", a.getFactoryDefaults) g.POST("/update", a.updateSetting) + g.POST("/testTgBot", a.testTgBot) g.POST("/validateRegex", a.validateRegex) g.POST("/updateUser", a.updateUser) g.POST("/restartPanel", a.restartPanel) @@ -118,7 +119,11 @@ func (a *SettingController) updateSetting(c *gin.Context) { } allSetting := &form.AllSetting oldPanelOutbound, _ := a.settingService.GetPanelOutbound() - err := a.settingService.UpdateAllSetting(allSetting) + oldTgEnable, _ := a.settingService.GetTgbotEnabled() + oldTgToken, _ := a.settingService.GetTgBotToken() + oldTgChatId, _ := a.settingService.GetTgBotChatId() + oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer() + err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{TgBotToken: form.ClearTgBotToken}) if err == nil && form.PanelOutbound != oldPanelOutbound { // The egress bridge lives in the generated config; reconcile the // running core. One SOCKS inbound plus one routing rule — both @@ -127,9 +132,49 @@ func (a *SettingController) updateSetting(c *gin.Context) { logger.Warning("apply panel outbound change failed:", applyErr) } } + // UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare. + if err == nil && reloadTgbotFunc != nil { + tgChanged := oldTgEnable != allSetting.TgBotEnable || + (allSetting.TgBotEnable && (oldTgToken != allSetting.TgBotToken || + oldTgChatId != allSetting.TgBotChatId || + oldTgAPIServer != allSetting.TgBotAPIServer)) + if tgChanged { + reloadTgbotFunc() + } + } jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) } +func (a *SettingController) testTgBot(c *gin.Context) { + enabled, err := a.settingService.GetTgbotEnabled() + if err != nil || !enabled { + jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled")) + return + } + // Import tgbot package would create a circular dependency, so we call + // the test through the global function registered at startup. + if testTgFunc != nil { + if err := testTgFunc(); err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started")) +} + +// testTgFunc is set from web layer to test Telegram sending without circular imports. +var testTgFunc func() error + +// SetTestTgFunc registers the function used to test Telegram sending. +func SetTestTgFunc(fn func() error) { testTgFunc = fn } + +// reloadTgbotFunc is wired from the web layer; importing tgbot here would be a circular dependency. +var reloadTgbotFunc func() + +func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn } + // updateUser updates the current user's username and password. func (a *SettingController) updateUser(c *gin.Context) { form := &updateUserForm{} diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index e8afa92..e14485e 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -38,6 +38,18 @@ type AllSetting struct { OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"` + TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` + TgBotToken string `json:"tgBotToken" form:"tgBotToken"` + TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` + TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` + TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` + TgRunTime string `json:"tgRunTime" form:"tgRunTime"` + TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` + TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` + TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"` + TgLang string `json:"tgLang" form:"tgLang"` + TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"` + TimeLocation string `json:"timeLocation" form:"timeLocation"` SubEnable bool `json:"subEnable" form:"subEnable"` @@ -83,6 +95,7 @@ type AllSettingView struct { AllSetting HasApiToken bool `json:"hasApiToken"` + HasTgBotToken bool `json:"hasTgBotToken"` HasWarpSecret bool `json:"hasWarpSecret"` HasNordSecret bool `json:"hasNordSecret"` } diff --git a/internal/web/job/check_hash_storage.go b/internal/web/job/check_hash_storage.go new file mode 100644 index 0000000..e51a6a8 --- /dev/null +++ b/internal/web/job/check_hash_storage.go @@ -0,0 +1,22 @@ +package job + +import "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" + +// CheckHashStorageJob periodically cleans up expired hash entries from the Telegram bot's hash storage. +type CheckHashStorageJob struct { + tgbotService tgbot.Tgbot +} + +// NewCheckHashStorageJob creates a new hash storage cleanup job instance. +func NewCheckHashStorageJob() *CheckHashStorageJob { + return new(CheckHashStorageJob) +} + +// Run removes expired hash entries from the Telegram bot's hash storage. +func (j *CheckHashStorageJob) Run() { + storage := j.tgbotService.GetHashStorage() + if storage == nil { + return + } + storage.RemoveExpiredHashes() +} diff --git a/internal/web/job/stats_notify_job.go b/internal/web/job/stats_notify_job.go new file mode 100644 index 0000000..0094dd0 --- /dev/null +++ b/internal/web/job/stats_notify_job.go @@ -0,0 +1,36 @@ +package job + +import ( + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" +) + +// LoginStatus represents the status of a login attempt. +type LoginStatus byte + +const ( + LoginSuccess LoginStatus = 1 // Successful login + LoginFail LoginStatus = 0 // Failed login attempt +) + +// StatsNotifyJob sends periodic statistics reports via Telegram bot. +type StatsNotifyJob struct { + xrayService service.XrayService + tgbotService tgbot.Tgbot +} + +// NewStatsNotifyJob creates a new statistics notification job instance. +func NewStatsNotifyJob() *StatsNotifyJob { + return new(StatsNotifyJob) +} + +// Run sends a statistics report via Telegram bot. +// +// Lite-build adaptation: upstream gates the report on the local Xray core +// running, but this panel never runs a local core — the gate would mute the +// periodic report and the scheduled database backup (tgBotBackup) forever. +// The report content (panel host usage, exhausted clients, DB backup) does +// not depend on a local core, so the job always sends. +func (j *StatsNotifyJob) Run() { + j.tgbotService.SendReport() +} diff --git a/internal/web/locale/locale.go b/internal/web/locale/locale.go index 0ee642c..95e854b 100644 --- a/internal/web/locale/locale.go +++ b/internal/web/locale/locale.go @@ -19,17 +19,24 @@ import ( var ( i18nBundle *i18n.Bundle LocalizerWeb *i18n.Localizer + LocalizerBot *i18n.Localizer ) // I18nType represents the type of interface for internationalization. type I18nType string const ( + Bot I18nType = "bot" // Bot interface type Web I18nType = "web" // Web interface type ) +// SettingService interface defines methods for accessing locale settings. +type SettingService interface { + GetTgLang() (string, error) +} + // InitLocalizer initializes the internationalization system with embedded translation files. -func InitLocalizer(i18nFS embed.FS) error { +func InitLocalizer(i18nFS embed.FS, settingService SettingService) error { // set default bundle to English i18nBundle = i18n.NewBundle(language.MustParse("en-US")) i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal) @@ -39,6 +46,11 @@ func InitLocalizer(i18nFS embed.FS) error { return err } + // setup bot locale + if err := initTGBotLocalizer(settingService); err != nil { + return err + } + return nil } @@ -59,12 +71,14 @@ func createTemplateData(params []string, separator ...string) map[string]any { } // I18n retrieves a localized message for the given key and type. -// It supports web contexts, with optional template parameters. +// It supports both bot and web contexts, with optional template parameters. // Returns the localized message or an empty string if localization fails. func I18n(i18nType I18nType, key string, params ...string) string { var localizer *i18n.Localizer switch i18nType { + case "bot": + localizer = LocalizerBot case "web": localizer = LocalizerWeb default: @@ -91,6 +105,17 @@ func I18n(i18nType I18nType, key string, params ...string) string { return msg } +// initTGBotLocalizer initializes the bot localizer with the configured language. +func initTGBotLocalizer(settingService SettingService) error { + botLang, err := settingService.GetTgLang() + if err != nil { + return err + } + + LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang) + return nil +} + // LocalizerMiddleware returns a Gin middleware that sets up localization for web requests. // It determines the user's language from cookies or Accept-Language header, // creates a localizer instance, and stores it in the Gin context for use in handlers. diff --git a/internal/web/service/inbound_tgbot_lookup_test.go b/internal/web/service/inbound_tgbot_lookup_test.go new file mode 100644 index 0000000..95cd056 --- /dev/null +++ b/internal/web/service/inbound_tgbot_lookup_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// TestGetClientTrafficTgBot_SettingsSerializationStyles guards against the +// prefilter regressing into a formatting-sensitive string match (#5805): the +// lookup must find clients whether inbounds.settings stores compact JSON +// ("tgId":N, as written by node sync/import) or indented JSON ("tgId": N, as +// written by the panel's MarshalIndent). +func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + + const tgId int64 = 123456789 + cases := []struct { + name string + settings string + email string + port int + }{ + {"compact", `{"clients":[{"id":"u1","email":"compact-user","tgId":123456789}]}`, "compact-user", 41001}, + {"spaced", `{"clients": [{"id": "u2", "email": "spaced-user", "tgId": 123456789}]}`, "spaced-user", 41002}, + } + for _, c := range cases { + inbound := &model.Inbound{UserId: 1, Tag: "tg-" + c.name, Enable: true, Port: c.port, Protocol: model.VLESS, Settings: c.settings} + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("create %s inbound: %v", c.name, err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: c.email, Enable: true, Up: 10, Down: 20}).Error; err != nil { + t.Fatalf("create %s client_traffics: %v", c.name, err) + } + } + + svc := InboundService{} + traffics, err := svc.GetClientTrafficTgBot(tgId) + if err != nil { + t.Fatalf("GetClientTrafficTgBot: %v", err) + } + got := make(map[string]bool, len(traffics)) + for _, tr := range traffics { + got[tr.Email] = true + } + if len(traffics) != 2 || !got["compact-user"] || !got["spaced-user"] { + t.Fatalf("expected traffic for compact-user and spaced-user, got %v", got) + } + + other, err := svc.GetClientTrafficTgBot(42) + if err != nil { + t.Fatalf("GetClientTrafficTgBot(42): %v", err) + } + if len(other) != 0 { + t.Fatalf("expected no traffic for unknown tgId, got %d rows", len(other)) + } +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 2e3c9c1..952e301 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "slices" + "strconv" "strings" "time" @@ -1179,3 +1180,71 @@ func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.Client return traffic, nil } +func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) { + db := database.GetDB() + + idQuery := fmt.Sprintf( + "SELECT DISTINCT inbounds.id %s WHERE %s = ?", + database.JSONClientsFromInbound(), + database.JSONFieldText("client.value", "tgId"), + ) + var inboundIds []int + if err := db.Raw(idQuery, strconv.FormatInt(tgId, 10)).Scan(&inboundIds).Error; err != nil { + logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err) + return nil, err + } + + var inbounds []*model.Inbound + if len(inboundIds) > 0 { + err := db.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err) + return nil, err + } + } + + var emails []string + for _, inbound := range inbounds { + clients, err := s.GetClients(inbound) + if err != nil { + logger.Errorf("Error retrieving clients for inbound %d: %v", inbound.Id, err) + continue + } + for _, client := range clients { + if client.TgID == tgId { + emails = append(emails, client.Email) + } + } + } + + // Chunked to stay under SQLite's bind-variable limit when a single Telegram + // account owns thousands of clients across inbounds. + uniqEmails := uniqueNonEmptyStrings(emails) + traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails)) + for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) { + var page []*xray.ClientTraffic + if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err) + return nil, err + } + traffics = append(traffics, page...) + } + if len(traffics) == 0 { + logger.Warning("No ClientTraffic records found for emails:", emails) + return nil, nil + } + + // Populate UUID and other client data for each traffic record + for i := range traffics { + if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { + traffics[i].Enable = client.Enable + traffics[i].UUID = client.ID + traffics[i].SubId = client.SubID + } + } + + return traffics, nil +} diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index e5e51f6..f002478 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -112,6 +112,19 @@ var defaultValueMap = map[string]string{ // Event bus — per-subscriber event filtering (empty = all disabled) // Consecutive failed observatory probes before an outbound.down event fires + "tgBotEnable": "false", + "tgBotToken": "", + "tgBotProxy": "", + "tgBotAPIServer": "", + "tgBotChatId": "", + "tgRunTime": "@daily", + "tgBotBackup": "false", + "tgCpu": "80", + "tgMemory": "80", + "tgLang": "en-US", + // Event bus — per-subscriber event filtering (empty = all disabled) + "tgEnabledEvents": "login.attempt,cpu.high", + "outboundDownThreshold": "3", } @@ -209,6 +222,8 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { return nil, err } view := &entity.AllSettingView{AllSetting: *allSetting} + view.HasTgBotToken = secretConfigured(allSetting.TgBotToken) + view.TgBotToken = "" view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasNordSecret = secretConfigured(mustString(s.GetNord())) var apiTokenCount int64 @@ -761,6 +776,83 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) { // GetOutboundDownThreshold returns how many consecutive failed observatory // probes an outbound must accumulate before an outbound.down notification is // emitted. 1 preserves the legacy "notify on the first failed probe" behaviour. + +func (s *SettingService) GetTgBotToken() (string, error) { + return s.getString("tgBotToken") +} + +func (s *SettingService) SetTgBotToken(token string) error { + return s.setString("tgBotToken", token) +} + +func (s *SettingService) GetTgBotProxy() (string, error) { + return s.getString("tgBotProxy") +} + +func (s *SettingService) SetTgBotProxy(token string) error { + return s.setString("tgBotProxy", token) +} + +func (s *SettingService) GetTgBotAPIServer() (string, error) { + return s.getString("tgBotAPIServer") +} + +func (s *SettingService) SetTgBotAPIServer(token string) error { + return s.setString("tgBotAPIServer", token) +} + +func (s *SettingService) GetTgBotChatId() (string, error) { + return s.getString("tgBotChatId") +} + +func (s *SettingService) SetTgBotChatId(chatIds string) error { + return s.setString("tgBotChatId", chatIds) +} + +func (s *SettingService) GetTgbotEnabled() (bool, error) { + return s.getBool("tgBotEnable") +} + +func (s *SettingService) SetTgbotEnabled(value bool) error { + return s.setBool("tgBotEnable", value) +} + +func (s *SettingService) GetTgbotRuntime() (string, error) { + return s.getString("tgRunTime") +} + +func (s *SettingService) SetTgbotRuntime(time string) error { + return s.setString("tgRunTime", time) +} + +func (s *SettingService) GetTgBotBackup() (bool, error) { + return s.getBool("tgBotBackup") +} + +func (s *SettingService) GetTgCpu() (int, error) { + return s.getInt("tgCpu") +} + +func (s *SettingService) GetTgMemory() (int, error) { + return s.getInt("tgMemory") +} + +func (s *SettingService) SetTgMemory(value int) error { + return s.setInt("tgMemory", value) +} + +func (s *SettingService) GetTgLang() (string, error) { + return s.getString("tgLang") +} + +func (s *SettingService) GetTgEnabledEvents() (string, error) { + return s.getString("tgEnabledEvents") +} + +func (s *SettingService) SetTgEnabledEvents(events string) error { + return s.setString("tgEnabledEvents", events) +} + func (s *SettingService) GetOutboundDownThreshold() (int, error) { return s.getInt("outboundDownThreshold") } @@ -769,7 +861,17 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error { return s.setInt("outboundDownThreshold", value) } -func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { +// SecretClears marks redacted secrets the user explicitly emptied. Without a +// flag, a blank submitted secret means "unchanged" (the field is always served +// blank to the browser) and the stored value is preserved. +type SecretClears struct { + TgBotToken bool +} + +func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error { + if err := s.preserveRedactedSecrets(allSetting, clears); err != nil { + return err + } if err := validateSettingsURLs(allSetting); err != nil { return err } @@ -858,7 +960,25 @@ func ValidateRegex(pattern string) error { return nil } +func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error { + if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" { + value, err := s.GetTgBotToken() + if err != nil { + return err + } + allSetting.TgBotToken = value + } + return nil +} + func validateSettingsURLs(allSetting *entity.AllSetting) error { + if allSetting.TgBotAPIServer != "" { + u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer) + if err != nil { + return common.NewError("telegram API server URL is invalid:", err) + } + allSetting.TgBotAPIServer = u + } // Support/profile links land in subscription headers and page data, where // client apps resolve a scheme-less value against the panel's own domain. // Non-http schemes (tg://, mailto:) are legitimate here, so only default diff --git a/internal/web/service/setting_clash_test.go b/internal/web/service/setting_clash_test.go index d62bb78..ba2145b 100644 --- a/internal/web/service/setting_clash_test.go +++ b/internal/web/service/setting_clash_test.go @@ -79,7 +79,7 @@ func TestUpdateAllSettingPreservesEmptyUserAgentRegexes(t *testing.T) { settings.SubJsonUserAgentRegex = " " settings.SubClashUserAgentRegex = "" - if err := s.UpdateAllSetting(settings); err != nil { + if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { t.Fatal(err) } @@ -129,7 +129,7 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) { settings.SubClashPath = "/clash-custom/" settings.SubClashURI = "https://subscriptions.example.com/clash-custom/" - if err := s.UpdateAllSetting(settings); err != nil { + if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { t.Fatal(err) } @@ -180,7 +180,7 @@ func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) { t.Fatal(err) } settings.SubClashUserAgentRegex = "[" - if err := s.UpdateAllSetting(settings); err == nil { + if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil { t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex") } } @@ -193,7 +193,7 @@ func TestUpdateAllSettingRejectsInvalidJsonUserAgentRegex(t *testing.T) { t.Fatal(err) } settings.SubJsonUserAgentRegex = "[" - if err := s.UpdateAllSetting(settings); err == nil { + if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil { t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex") } } diff --git a/internal/web/service/setting_sub_identity_test.go b/internal/web/service/setting_sub_identity_test.go index 1d15f5b..004c991 100644 --- a/internal/web/service/setting_sub_identity_test.go +++ b/internal/web/service/setting_sub_identity_test.go @@ -18,7 +18,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) { } settings.SubShowIdentityOnAllLinks = true - if err := s.UpdateAllSetting(settings); err != nil { + if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { t.Fatal(err) } if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got { @@ -30,7 +30,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) { t.Fatal(err) } settings.SubShowIdentityOnAllLinks = false - if err := s.UpdateAllSetting(settings); err != nil { + if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { t.Fatal(err) } if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got { diff --git a/internal/web/service/tgbot/numeric_input.go b/internal/web/service/tgbot/numeric_input.go new file mode 100644 index 0000000..37e90a9 --- /dev/null +++ b/internal/web/service/tgbot/numeric_input.go @@ -0,0 +1,17 @@ +package tgbot + +// updateNumericInput applies one number-pad key: -2 clears, -1 backspaces, and 0..9 append. +// Callers retain their own validation and keyboard labels. +func updateNumericInput(value, key int) int { + switch key { + case -2: + return 0 + case -1: + if value > 0 { + return value / 10 + } + return value + default: + return value*10 + key + } +} diff --git a/internal/web/service/tgbot/numeric_input_test.go b/internal/web/service/tgbot/numeric_input_test.go new file mode 100644 index 0000000..958e295 --- /dev/null +++ b/internal/web/service/tgbot/numeric_input_test.go @@ -0,0 +1,81 @@ +package tgbot + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateNumericInput(t *testing.T) { + tests := []struct { + name string + value, key int + want int + }{ + {name: "append digit", value: 12, key: 3, want: 123}, + {name: "append zero", value: 12, key: 0, want: 120}, + {name: "backspace", value: 123, key: -1, want: 12}, + {name: "backspace zero", value: 0, key: -1, want: 0}, + {name: "clear", value: 123, key: -2, want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := updateNumericInput(tt.value, tt.key); got != tt.want { + t.Fatalf("updateNumericInput(%d, %d) = %d, want %d", tt.value, tt.key, got, tt.want) + } + }) + } +} + +func TestNumericInputTransitionIsUsedByEveryKeypad(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read tgbot package: %v", err) + } + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || filepath.Ext(name) != ".go" || name == "numeric_input.go" || filepath.Ext(strings.TrimSuffix(name, "_test.go")) != ".go" { + continue + } + parsed, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + ast.Inspect(parsed, func(node ast.Node) bool { + switchStmt, ok := node.(*ast.SwitchStmt) + if !ok { + return true + } + hasClear, hasBackspace, hasDefault := false, false, false + for _, stmt := range switchStmt.Body.List { + clause := stmt.(*ast.CaseClause) + if clause.List == nil { + hasDefault = true + } + for _, expr := range clause.List { + hasClear = hasClear || numericKeyLiteral(expr, "2") + hasBackspace = hasBackspace || numericKeyLiteral(expr, "1") + } + } + if hasClear && hasBackspace && hasDefault { + position := fset.Position(switchStmt.Pos()) + t.Errorf("open-coded numeric keypad transition at %s; use updateNumericInput", position) + } + return true + }) + } +} + +func numericKeyLiteral(expr ast.Expr, magnitude string) bool { + unary, ok := expr.(*ast.UnaryExpr) + if !ok || unary.Op != token.SUB { + return false + } + literal, ok := unary.X.(*ast.BasicLit) + return ok && literal.Kind == token.INT && literal.Value == magnitude +} diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go new file mode 100644 index 0000000..d7514c7 --- /dev/null +++ b/internal/web/service/tgbot/tgbot.go @@ -0,0 +1,537 @@ +package tgbot + +import ( + "context" + "crypto/rand" + "embed" + "math/big" + "net/http" + "net/url" + "os" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/web/global" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + "github.com/valyala/fasthttp" + "github.com/valyala/fasthttp/fasthttpproxy" +) + +var ( + bot *telego.Bot + + // botCancel stores the function to cancel the context, stopping Long Polling gracefully. + botCancel context.CancelFunc + // tgBotMutex protects concurrent access to botCancel variable + tgBotMutex sync.Mutex + // botWG waits for the OnReceive Long Polling goroutine to finish. + botWG sync.WaitGroup + + botHandler *th.BotHandler + adminIds []int64 + isRunning bool + hostname string + hashStorage *global.HashStorage + + // EventBus is set from web layer to publish login/security events. + EventBus *eventbus.Bus + + // Performance improvements + messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing + optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts + + // Simple cache for frequently accessed data + statusCache struct { + data *service.Status + timestamp time.Time + mutex sync.RWMutex + } + + serverStatsCache struct { + data string + timestamp time.Time + mutex sync.RWMutex + } + + // clients data to adding new client. receiver_inbound_IDs is the set of + // inbounds the new client will be attached to; receiver_inbound_ID mirrors + // the primary pick for the legacy attach-picker entry point. Per-protocol + // secrets (UUID, password, flow, method) are filled per-inbound on submit + // by ClientService.fillProtocolDefaults, so the bot only tracks universal + // client fields here. + receiver_inbound_ID int + receiver_inbound_IDs []int + client_Email string + client_LimitIP int + client_TotalGB int64 + client_ExpiryTime int64 + client_Enable bool + client_TgID string + client_SubID string + client_Comment string + client_Reset int +) + +// userStateStore guards the per-chat conversation states. The Telegram command +// and callback handlers run on a worker-pool goroutine while the message handler +// runs on the dispatch goroutine, so a bare map would be a concurrent-map-write +// crash. It also expires abandoned conversations so a user who starts a flow and +// goes silent doesn't leave an entry forever. +type userStateStore struct { + mu sync.Mutex + states map[int64]userStateEntry + lastPrune time.Time +} + +type userStateEntry struct { + state string + at time.Time +} + +var userStateMgr = &userStateStore{states: make(map[int64]userStateEntry)} + +func (s *userStateStore) set(chatID int64, state string) { + s.mu.Lock() + s.states[chatID] = userStateEntry{state: state, at: time.Now()} + s.mu.Unlock() +} + +func (s *userStateStore) get(chatID int64) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.states[chatID] + return e.state, ok +} + +func (s *userStateStore) clear(chatID int64) { + s.mu.Lock() + delete(s.states, chatID) + s.mu.Unlock() +} + +func (s *userStateStore) reset() { + s.mu.Lock() + s.states = make(map[int64]userStateEntry) + s.mu.Unlock() +} + +// maybePrune drops conversations older than maxAge, at most once per maxAge so a +// busy bot doesn't sweep the whole map on every message. +func (s *userStateStore) maybePrune(maxAge time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + if now.Sub(s.lastPrune) < maxAge { + return + } + s.lastPrune = now + for id, e := range s.states { + if now.Sub(e.at) > maxAge { + delete(s.states, id) + } + } +} + +// LoginStatus represents the result of a login attempt. +type LoginStatus byte + +// Login status constants +const ( + LoginSuccess LoginStatus = 1 // Login was successful + LoginFail LoginStatus = 0 // Login failed + EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID +) + +// LoginAttempt contains safe metadata for panel login notifications. +// It intentionally does not include attempted passwords. +type LoginAttempt struct { + Username string + IP string + Time string + Status LoginStatus + Reason string +} + +// Tgbot provides business logic for Telegram bot integration. +// It handles bot commands, user interactions, and status reporting via Telegram. +type Tgbot struct { + inboundService service.InboundService + clientService service.ClientService + settingService service.SettingService + serverService service.ServerService + xrayService service.XrayService + lastStatus *service.Status +} + +// NewTgbot creates a new Tgbot instance. +func (t *Tgbot) NewTgbot() *Tgbot { + return new(Tgbot) +} + +// I18nBot retrieves a localized message for the bot interface. +func (t *Tgbot) I18nBot(name string, params ...string) string { + return locale.I18n(locale.Bot, name, params...) +} + +// GetHashStorage returns the hash storage instance for callback queries. +func (t *Tgbot) GetHashStorage() *global.HashStorage { + return hashStorage +} + +// getCachedStatus returns cached server status if it's fresh enough (less than 5 seconds old) +func (t *Tgbot) getCachedStatus() (*service.Status, bool) { + statusCache.mutex.RLock() + defer statusCache.mutex.RUnlock() + + if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second { + return statusCache.data, true + } + return nil, false +} + +// setCachedStatus updates the status cache +func (t *Tgbot) setCachedStatus(status *service.Status) { + statusCache.mutex.Lock() + defer statusCache.mutex.Unlock() + + statusCache.data = status + statusCache.timestamp = time.Now() +} + +// getCachedServerStats returns cached server stats if it's fresh enough (less than 10 seconds old) +func (t *Tgbot) getCachedServerStats() (string, bool) { + serverStatsCache.mutex.RLock() + defer serverStatsCache.mutex.RUnlock() + + if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second { + return serverStatsCache.data, true + } + return "", false +} + +// setCachedServerStats updates the server stats cache +func (t *Tgbot) setCachedServerStats(stats string) { + serverStatsCache.mutex.Lock() + defer serverStatsCache.mutex.Unlock() + + serverStatsCache.data = stats + serverStatsCache.timestamp = time.Now() +} + +// Start initializes and starts the Telegram bot with the provided translation files. +func (t *Tgbot) Start(i18nFS embed.FS) error { + // Initialize localizer + err := locale.InitLocalizer(i18nFS, &t.settingService) + if err != nil { + return err + } + + // If Start is called again (e.g. during reload), ensure any previous long-polling + // loop is stopped before creating a new bot / receiver. + StopBot() + + // Initialize hash storage to store callback queries + hashStorage = global.NewHashStorage(20 * time.Minute) + + // Initialize worker pool for concurrent message processing (max 10 concurrent handlers) + messageWorkerPool = make(chan struct{}, 10) + + // Initialize optimized HTTP client with connection pooling + optimizedHTTPClient = &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 30 * time.Second, + DisableKeepAlives: false, + }, + } + + t.SetHostname() + + // Get Telegram bot token + tgBotToken, err := t.settingService.GetTgBotToken() + if err != nil || tgBotToken == "" { + logger.Warning("Failed to get Telegram bot token:", err) + return err + } + + // Get Telegram bot chat ID(s) + tgBotID, err := t.settingService.GetTgBotChatId() + if err != nil { + logger.Warning("Failed to get Telegram bot chat ID:", err) + return err + } + + parsedAdminIds := make([]int64, 0) + // Parse admin IDs from comma-separated string + if tgBotID != "" { + for adminID := range strings.SplitSeq(tgBotID, ",") { + id, err := strconv.ParseInt(adminID, 10, 64) + if err != nil { + logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err) + return err + } + parsedAdminIds = append(parsedAdminIds, id) + } + } + tgBotMutex.Lock() + adminIds = parsedAdminIds + tgBotMutex.Unlock() + + // Get Telegram bot proxy URL + tgBotProxy, err := t.settingService.GetTgBotProxy() + if err != nil { + logger.Warning("Failed to get Telegram bot proxy URL:", err) + } + + // Fall back to the panel-wide egress bridge when no dedicated bot proxy is + // set. Resolved once at bot start: if Xray comes up later, the bot keeps + // its direct connection until it is restarted. + if tgBotProxy == "" { + if egress := t.settingService.PanelEgressProxyURL(); egress != "" && isSupportedBotProxyScheme(egress) { + tgBotProxy = egress + } + } + + // Get Telegram bot API server URL + tgBotAPIServer, err := t.settingService.GetTgBotAPIServer() + if err != nil { + logger.Warning("Failed to get Telegram bot API server URL:", err) + } + + // Create new Telegram bot instance + bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer) + if err != nil { + logger.Error("Failed to initialize Telegram bot API:", err) + return err + } + + t.trySetBotCommands(bot) + + // Start receiving Telegram bot messages + tgBotMutex.Lock() + alreadyRunning := isRunning || botCancel != nil + tgBotMutex.Unlock() + if !alreadyRunning { + logger.Info("Telegram bot receiver started") + go t.OnReceive() + } + + return nil +} + +func (t *Tgbot) trySetBotCommands(bot *telego.Bot) { + defer func() { + if r := recover(); r != nil { + logger.Warning("Failed to register bot commands (Telegram may be rate-limiting); bot will continue without them:", r) + } + }() + + err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{ + Commands: []telego.BotCommand{ + {Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")}, + {Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")}, + {Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")}, + {Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")}, + {Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")}, + {Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")}, + {Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")}, + {Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")}, + }, + }) + if err != nil { + logger.Warning("Failed to set bot commands:", err) + } +} + +func isSupportedBotProxyScheme(proxyUrl string) bool { + return strings.HasPrefix(proxyUrl, "socks5://") || + strings.HasPrefix(proxyUrl, "http://") || + strings.HasPrefix(proxyUrl, "https://") +} + +// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling +func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client { + client := &fasthttp.Client{ + // Connection timeouts + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + MaxIdleConnDuration: 60 * time.Second, + MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration + MaxIdemponentCallAttempts: 3, + ReadBufferSize: 4096, + WriteBufferSize: 4096, + MaxConnsPerHost: 100, + MaxConnWaitTimeout: 10 * time.Second, + DisableHeaderNamesNormalizing: false, + DisablePathNormalizing: false, + // resetTimeout stays false to keep the pre-RetryIfErr retry timing. + RetryIfErr: func(request *fasthttp.Request, _ int, _ error) (bool, bool) { + method := string(request.Header.Method()) + return false, method == "GET" || method == "POST" + }, + } + + if proxyUrl != "" { + if strings.HasPrefix(proxyUrl, "socks5://") { + client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl) + } else { + client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxyUrl) + } + } + + return client +} + +// NewBot creates a new Telegram bot instance with optional proxy and API server settings. +func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) { + // Validate proxy URL if provided + if proxyUrl != "" { + if !isSupportedBotProxyScheme(proxyUrl) { + logger.Warning("Unsupported proxy scheme (want socks5:// or http(s)://), ignoring proxy") + proxyUrl = "" // Clear invalid proxy + } else if _, err := url.Parse(proxyUrl); err != nil { + logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err) + proxyUrl = "" + } + } + + // Validate API server URL if provided + if apiServerUrl != "" { + safeURL, err := service.SanitizePublicHTTPURL(apiServerUrl, false) + if err != nil { + logger.Warningf("Invalid or blocked API server URL, using default: %v", err) + apiServerUrl = "" + } else { + apiServerUrl = safeURL + } + } + + // Create robust fasthttp client + client := t.createRobustFastHTTPClient(proxyUrl) + + // Build bot options + var options []telego.BotOption + options = append(options, telego.WithFastHTTPClient(client)) + + if apiServerUrl != "" { + options = append(options, telego.WithAPIServer(apiServerUrl)) + } + + return telego.NewBot(token, options...) +} + +// IsRunning checks if the Telegram bot is currently running. +func (t *Tgbot) IsRunning() bool { + tgBotMutex.Lock() + defer tgBotMutex.Unlock() + return isRunning +} + +// SetHostname sets the hostname for the bot. +func (t *Tgbot) SetHostname() { + host, err := os.Hostname() + if err != nil { + logger.Error("get hostname error:", err) + hostname = "" + return + } + hostname = host +} + +// Stop safely stops the Telegram bot's Long Polling operation. +// This method now calls the global StopBot function and cleans up other resources. +func (t *Tgbot) Stop() { + StopBot() + logger.Info("Stop Telegram receiver ...") + tgBotMutex.Lock() + adminIds = nil + tgBotMutex.Unlock() +} + +// StopBot safely stops the Telegram bot's Long Polling operation by cancelling its context. +// This is the global function called from main.go's signal handler and t.Stop(). +func StopBot() { + // Don't hold the mutex while cancelling/waiting. + tgBotMutex.Lock() + cancel := botCancel + botCancel = nil + handler := botHandler + botHandler = nil + isRunning = false + tgBotMutex.Unlock() + + userStateMgr.reset() + + if handler != nil { + _ = handler.Stop() + } + + if cancel != nil { + logger.Info("Sending cancellation signal to Telegram bot...") + // Cancels the context passed to UpdatesViaLongPolling; this closes updates channel + // and lets botHandler.Start() exit cleanly. + cancel() + botWG.Wait() + logger.Info("Telegram bot successfully stopped.") + } +} + +// encodeQuery encodes the query string if it's longer than 64 characters. +func (t *Tgbot) encodeQuery(query string) string { + // NOTE: we only need to hash for more than 64 chars + if len(query) <= 64 { + return query + } + + return hashStorage.SaveHash(query) +} + +// decodeQuery decodes a hashed query string back to its original form. +func (t *Tgbot) decodeQuery(query string) (string, error) { + if !hashStorage.IsMD5(query) { + return query, nil + } + + decoded, exists := hashStorage.GetValue(query) + if !exists { + return "", common.NewError("hash not found in storage!") + } + + return decoded, nil +} + +// randomLowerAndNum generates a random string of lowercase letters and numbers. +func (t *Tgbot) randomLowerAndNum(length int) string { + charset := "abcdefghijklmnopqrstuvwxyz0123456789" + bytes := make([]byte, length) + for i := range bytes { + randomIndex, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + bytes[i] = charset[randomIndex.Int64()] + } + return string(bytes) +} + +// int64Contains checks if an int64 slice contains a specific item. +func int64Contains(slice []int64, item int64) bool { + return slices.Contains(slice, item) +} + +// isSingleWord checks if the text contains only a single word. +func (t *Tgbot) isSingleWord(text string) bool { + text = strings.TrimSpace(text) + re := regexp.MustCompile(`\s+`) + return re.MatchString(text) +} diff --git a/internal/web/service/tgbot/tgbot_callback_auth_test.go b/internal/web/service/tgbot/tgbot_callback_auth_test.go new file mode 100644 index 0000000..6e8c78e --- /dev/null +++ b/internal/web/service/tgbot/tgbot_callback_auth_test.go @@ -0,0 +1,40 @@ +package tgbot + +import ( + "testing" + + "github.com/mymmrac/telego" +) + +func TestAnswerCallbackDeniesPrivilegedActionToNonAdmin(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("a non-admin callback reached a privileged handler: %v", r) + } + }() + + tg := &Tgbot{} + for _, data := range []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "inbounds"} { + q := &telego.CallbackQuery{ + Data: data, + From: telego.User{ID: 999999}, + Message: &telego.Message{Chat: telego.Chat{ID: 1}}, + } + tg.answerCallback(q, false) + } +} + +func TestIsClientSelfCallback(t *testing.T) { + allowed := []string{"client_traffic", "client_sub_links", "client_qr_links", "client_sub_links alice@x"} + for _, d := range allowed { + if !isClientSelfCallback(d) { + t.Errorf("%q should be a per-user client callback", d) + } + } + denied := []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "get_banlogs", "get_usage"} + for _, d := range denied { + if isClientSelfCallback(d) { + t.Errorf("%q is an admin-only callback and must not be treated as per-user", d) + } + } +} diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go new file mode 100644 index 0000000..ca9f483 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_client.go @@ -0,0 +1,782 @@ +package tgbot + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + "github.com/skip2/go-qrcode" +) + +// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress +// client (email, attached inbounds, traffic limit, expiry, ip limit, comment) +// shown in the multi-inbound add flow. Per-protocol secrets (UUID, password, +// flow, method) are generated by fillProtocolDefaults on submit, so the bot +// never has to track them per inbound itself. +func (t *Tgbot) BuildClientDraftMessage() string { + now := time.Now().UnixMilli() + + expiry := "" + switch { + case client_ExpiryTime == 0: + expiry = t.I18nBot("tgbot.unlimited") + case client_ExpiryTime < 0: + expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days")) + default: + diff := client_ExpiryTime - now + if diff > 172800000 { + expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05") + } else { + expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours")) + } + } + + traffic := "♾️ Unlimited(Reset)" + if client_TotalGB > 0 { + traffic = common.FormatTraffic(client_TotalGB) + } + + ipLimit := "♾️ Unlimited(Reset)" + if client_LimitIP > 0 { + ipLimit = fmt.Sprint(client_LimitIP) + } + + attached := t.describeAttachedInbounds(receiver_inbound_IDs) + if attached == "" { + attached = "—" + } + + comment := client_Comment + if comment == "" { + comment = "—" + } + + tgID := client_TgID + if tgID == "" { + tgID = "—" + } + + var b strings.Builder + b.WriteString("📝 *New client draft*\r\n") + fmt.Fprintf(&b, "📧 Email: `%s`\r\n", client_Email) + fmt.Fprintf(&b, "🔗 Attached: %s\r\n", attached) + fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic) + fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry) + fmt.Fprintf(&b, "🔢 IP limit: %s\r\n", ipLimit) + fmt.Fprintf(&b, "👤 TG user: %s\r\n", tgID) + fmt.Fprintf(&b, "💬 Comment: %s\r\n", comment) + return b.String() +} + +// describeAttachedInbounds returns a short "remark1, remark2" list for the given +// inbound ids, falling back to "#id" when an inbound can't be loaded. +func (t *Tgbot) describeAttachedInbounds(ids []int) string { + if len(ids) == 0 { + return "" + } + parts := make([]string, 0, len(ids)) + for _, id := range ids { + ib, err := t.inboundService.GetInbound(id) + if err != nil || ib == nil { + parts = append(parts, fmt.Sprintf("#%d", id)) + continue + } + label := ib.Remark + if label == "" { + label = fmt.Sprintf("#%d", id) + } + parts = append(parts, label) + } + return strings.Join(parts, ", ") +} + +// SubmitAddClient sends the in-progress client to ClientService.Create with +// the full set of attached inbound ids. Per-inbound fillProtocolDefaults on +// the panel generates UUID/password/auth per protocol, so the bot only +// supplies the universal fields it actually collected. +func (t *Tgbot) SubmitAddClient() (bool, error) { + inboundIDs := receiver_inbound_IDs + if len(inboundIDs) == 0 && receiver_inbound_ID > 0 { + inboundIDs = []int{receiver_inbound_ID} + } + if len(inboundIDs) == 0 { + return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + + tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64) + client := model.Client{ + Email: client_Email, + Enable: client_Enable, + LimitIP: client_LimitIP, + TotalGB: client_TotalGB, + ExpiryTime: client_ExpiryTime, + SubID: client_SubID, + Comment: client_Comment, + Reset: client_Reset, + TgID: tgIDInt, + } + + return t.clientService.Create(&t.inboundService, &service.ClientCreatePayload{ + Client: client, + InboundIds: inboundIDs, + }) +} + +// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email +func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) { + // Resolve subId from client email + traffic, client, err := t.inboundService.GetClientByEmail(email) + _ = traffic + if err != nil || client == nil { + return "", "", errors.New("client not found") + } + + // Gather settings to construct absolute URLs + subURI, _ := t.settingService.GetSubURI() + subJsonURI, _ := t.settingService.GetSubJsonURI() + subDomain, _ := t.settingService.GetSubDomain() + subPort, _ := t.settingService.GetSubPort() + subPath, _ := t.settingService.GetSubPath() + subJsonPath, _ := t.settingService.GetSubJsonPath() + subJsonEnable, _ := t.settingService.GetSubJsonEnable() + subKeyFile, _ := t.settingService.GetSubKeyFile() + subCertFile, _ := t.settingService.GetSubCertFile() + + tls := (subKeyFile != "" && subCertFile != "") + scheme := "http" + if tls { + scheme = "https" + } + + // Fallbacks + if subDomain == "" { + // try panel domain, otherwise OS hostname + if d, err := t.settingService.GetWebDomain(); err == nil && d != "" { + subDomain = d + } else if hostname != "" { + subDomain = hostname + } else { + subDomain = "localhost" + } + } + + host := subDomain + if (subPort == 443 && tls) || (subPort == 80 && !tls) { + // standard ports: no port in host + } else { + host = fmt.Sprintf("%s:%d", subDomain, subPort) + } + + // Ensure paths + if !strings.HasPrefix(subPath, "/") { + subPath = "/" + subPath + } + if !strings.HasSuffix(subPath, "/") { + subPath = subPath + "/" + } + if !strings.HasPrefix(subJsonPath, "/") { + subJsonPath = "/" + subJsonPath + } + if !strings.HasSuffix(subJsonPath, "/") { + subJsonPath = subJsonPath + "/" + } + + var subURL string + var subJsonURL string + + // If pre-configured URIs are available, use them directly + if subURI != "" { + if !strings.HasSuffix(subURI, "/") { + subURI = subURI + "/" + } + subURL = fmt.Sprintf("%s%s", subURI, client.SubID) + } else { + subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID) + } + + if subJsonURI != "" { + if !strings.HasSuffix(subJsonURI, "/") { + subJsonURI = subJsonURI + "/" + } + subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID) + } else { + subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID) + } + + if !subJsonEnable { + subJsonURL = "" + } + return subURL, subJsonURL, nil +} + +// sendClientSubLinks sends the subscription links for the client to the chat. +func (t *Tgbot) sendClientSubLinks(chatId int64, email string) { + subURL, subJsonURL, err := t.buildSubscriptionURLs(email) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + msg := "Subscription URL:\r\n" + subURL + "" + if subJsonURL != "" { + msg += "\r\n\r\nJSON URL:\r\n" + subJsonURL + "" + } + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)), + ), + ) + t.SendMsgToTgbot(chatId, msg, inlineKeyboard) +} + +// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user +func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) { + // Build the HTML sub page URL; we'll call it with header Accept to get raw content + subURL, _, err := t.buildSubscriptionURLs(email) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + + // Try to fetch raw subscription links. Prefer plain text response. + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + // Force plain text to avoid HTML page; controller respects Accept header + req.Header.Set("Accept", "text/plain, */*;q=0.1") + + // Use optimized client with connection pooling + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req = req.WithContext(ctx) + + resp, err := optimizedHTTPClient.Do(req) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + + // If service is configured to encode (Base64), decode it + encoded, _ := t.settingService.GetSubEncrypt() + var content string + if encoded { + decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes)) + if err != nil { + // fallback to raw text + content = string(bodyBytes) + } else { + content = string(decoded) + } + } else { + content = string(bodyBytes) + } + + // Normalize line endings and trim + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + var cleaned []string + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + cleaned = append(cleaned, l) + } + } + if len(cleaned) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult")) + return + } + + // Send in chunks to respect message length; use monospace formatting + const maxPerMessage = 50 + for i := 0; i < len(cleaned); i += maxPerMessage { + j := min(i+maxPerMessage, len(cleaned)) + chunk := cleaned[i:j] + var msg strings.Builder + msg.WriteString(t.I18nBot("subscription.individualLinks")) + msg.WriteString(":\r\n") + for _, link := range chunk { + // wrap each link in + msg.WriteString("") + msg.WriteString(link) + msg.WriteString("\r\n") + } + t.SendMsgToTgbot(chatId, msg.String()) + } +} + +// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them +func (t *Tgbot) sendClientQRLinks(chatId int64, email string) { + subURL, subJsonURL, err := t.buildSubscriptionURLs(email) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + + // Helper to create QR PNG bytes from content + createQR := func(content string, size int) ([]byte, error) { + if size <= 0 { + size = 256 + } + return qrcode.Encode(content, qrcode.Medium, size) + } + + // Inform user + t.SendMsgToTgbot(chatId, "QRCode for client "+email+":") + + // Send sub URL QR (filename: sub.png) + if png, err := createQR(subURL, 320); err == nil { + document := tu.Document( + tu.ID(chatId), + tu.FileFromBytes(png, "sub.png"), + ) + _, _ = bot.SendDocument(context.Background(), document) + } else { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + } + + // Send JSON URL QR (filename: subjson.png) when available + if subJsonURL != "" { + if png, err := createQR(subJsonURL, 320); err == nil { + document := tu.Document( + tu.ID(chatId), + tu.FileFromBytes(png, "subjson.png"), + ) + _, _ = bot.SendDocument(context.Background(), document) + } else { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + } + } + + // Also generate a few individual links' QRs (first up to 5) + subPageURL := subURL + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil) + if err == nil { + req.Header.Set("Accept", "text/plain, */*;q=0.1") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req = req.WithContext(ctx) + if resp, err := optimizedHTTPClient.Do(req); err == nil { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + encoded, _ := t.settingService.GetSubEncrypt() + var content string + if encoded { + if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil { + content = string(dec) + } else { + content = string(body) + } + } else { + content = string(body) + } + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + var cleaned []string + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + cleaned = append(cleaned, l) + } + } + if len(cleaned) > 0 { + max := min(len(cleaned), 5) + for i := range max { + if png, err := createQR(cleaned[i], 320); err == nil { + // Use the email as filename for individual link QR + filename := email + ".png" + document := tu.Document( + tu.ID(chatId), + tu.FileFromBytes(png, filename), + ) + _, _ = bot.SendDocument(context.Background(), document) + // Reduced delay for better performance + if i < max-1 { // Only delay between documents, not after the last one + time.Sleep(50 * time.Millisecond) + } + } + } + } + } + } +} + +// clientInfoMsg formats client information message based on traffic and flags. +func (t *Tgbot) clientInfoMsg( + traffic *xray.ClientTraffic, + printEnabled bool, + printOnline bool, + printActive bool, + printDate bool, + printTraffic bool, + printRefreshed bool, +) string { + now := time.Now().Unix() + expiryTime := "" + flag := false + diff := traffic.ExpiryTime/1000 - now + if traffic.ExpiryTime == 0 { + expiryTime = t.I18nBot("tgbot.unlimited") + } else if diff > 172800 || !traffic.Enable { + expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") + if diff > 0 { + days := diff / 86400 + hours := (diff % 86400) / 3600 + minutes := (diff % 3600) / 60 + remainingTime := "" + if days > 0 { + remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days")) + } + if hours > 0 { + remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours")) + } + if minutes > 0 { + remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes")) + } + expiryTime += fmt.Sprintf(" (%s)", remainingTime) + } + } else if traffic.ExpiryTime < 0 { + expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days")) + flag = true + } else { + expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours")) + flag = true + } + + total := "" + if traffic.Total == 0 { + total = t.I18nBot("tgbot.unlimited") + } else { + total = common.FormatTraffic(traffic.Total) + } + + enabled := "" + isEnabled, err := t.clientService.CheckIsEnabledByEmail(&t.inboundService, traffic.Email) + if err != nil { + logger.Warning(err) + enabled = t.I18nBot("tgbot.wentWrong") + } else if isEnabled { + enabled = t.I18nBot("tgbot.messages.yes") + } else { + enabled = t.I18nBot("tgbot.messages.no") + } + + active := "" + if traffic.Enable { + active = t.I18nBot("tgbot.messages.yes") + } else { + active = t.I18nBot("tgbot.messages.no") + } + + status := t.I18nBot("tgbot.offline") + isOnline := false + if process := service.XrayProcess(); process != nil && process.IsRunning() { + if slices.Contains(process.GetOnlineClients(), traffic.Email) { + status = t.I18nBot("tgbot.online") + isOnline = true + } + } + + output := "" + output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email) + if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 { + output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds)) + } + if printEnabled { + output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled) + } + if printOnline { + output += t.I18nBot("tgbot.messages.online", "Status=="+status) + if !isOnline && traffic.LastOnline > 0 { + output += t.I18nBot("tgbot.messages.lastOnline", "Time=="+time.UnixMilli(traffic.LastOnline).Format("2006-01-02 15:04:05")) + } + } + if printActive { + output += t.I18nBot("tgbot.messages.active", "Enable=="+active) + } + if printDate { + if flag { + output += t.I18nBot("tgbot.messages.expireIn", "Time=="+expiryTime) + } else { + output += t.I18nBot("tgbot.messages.expire", "Time=="+expiryTime) + } + } + if printTraffic { + output += t.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(traffic.Up)) + output += t.I18nBot("tgbot.messages.download", "Download=="+common.FormatTraffic(traffic.Down)) + output += t.I18nBot("tgbot.messages.total", "UpDown=="+common.FormatTraffic((traffic.Up+traffic.Down)), "Total=="+total) + } + if printRefreshed { + output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + } + + return output +} + +// getClientUsage retrieves and sends client usage information to the chat. +func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) { + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + logger.Warning(err) + msg := t.I18nBot("tgbot.wentWrong") + t.SendMsgToTgbot(chatId, msg) + return + } + + if len(traffics) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + + output := "" + + if len(traffics) > 0 { + if len(email) > 0 { + for _, traffic := range traffics { + if traffic.Email == email[0] { + output := t.clientInfoMsg(traffic, true, true, true, true, true, true) + t.SendMsgToTgbot(chatId, output) + return + } + } + msg := t.I18nBot("tgbot.noResult") + t.SendMsgToTgbot(chatId, msg) + return + } else { + for _, traffic := range traffics { + output += t.clientInfoMsg(traffic, true, true, true, true, true, false) + output += "\r\n" + } + } + } + + output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + t.SendMsgToTgbot(chatId, output) + output = t.I18nBot("tgbot.commands.pleaseChoose") + t.SendAnswer(chatId, output, false) +} + +// searchClientIps searches and sends client IP addresses for the given email. +func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) { + ips, err := t.inboundService.GetInboundClientIps(email) + if err != nil || len(ips) == 0 { + ips = t.I18nBot("tgbot.noIpRecord") + } + + formattedIps := ips + if err == nil && len(ips) > 0 { + type ipWithTimestamp struct { + IP string `json:"ip"` + Timestamp int64 `json:"timestamp"` + } + + var ipsWithTime []ipWithTimestamp + if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 { + lines := make([]string, 0, len(ipsWithTime)) + for _, item := range ipsWithTime { + if item.IP == "" { + continue + } + if item.Timestamp > 0 { + ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05") + lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts)) + continue + } + lines = append(lines, item.IP) + } + if len(lines) > 0 { + formattedIps = strings.Join(lines, "\n") + } + } else { + var oldIps []string + if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 { + formattedIps = strings.Join(oldIps, "\n") + } + } + } + + output := "" + output += t.I18nBot("tgbot.messages.email", "Email=="+email) + output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps) + output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)), + ), + ) + + if len(messageID) > 0 { + t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) + } else { + t.SendMsgToTgbot(chatId, output, inlineKeyboard) + } +} + +// clientTelegramUserInfo retrieves and sends Telegram user info for the client. +func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) { + traffic, client, err := t.inboundService.GetClientByEmail(email) + if err != nil { + logger.Warning(err) + msg := t.I18nBot("tgbot.wentWrong") + t.SendMsgToTgbot(chatId, msg) + return + } + if client == nil { + msg := t.I18nBot("tgbot.noResult") + t.SendMsgToTgbot(chatId, msg) + return + } + tgId := "None" + if client.TgID != 0 { + tgId = strconv.FormatInt(client.TgID, 10) + } + + output := "" + output += t.I18nBot("tgbot.messages.email", "Email=="+email) + output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId) + output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)), + ), + ) + + if len(messageID) > 0 { + t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) + } else { + t.SendMsgToTgbot(chatId, output, inlineKeyboard) + requestUser := telego.KeyboardButtonRequestUsers{ + RequestID: int32(traffic.Id), + UserIsBot: new(bool), + } + keyboard := tu.Keyboard( + tu.KeyboardRow( + tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser), + ), + tu.KeyboardRow( + tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")), + ), + ).WithIsPersistent().WithResizeKeyboard() + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard) + } +} + +// searchClient searches for a client by email and sends the information. +func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) { + traffic, err := t.inboundService.GetClientTrafficByEmail(email) + if err != nil { + logger.Warning(err) + msg := t.I18nBot("tgbot.wentWrong") + t.SendMsgToTgbot(chatId, msg) + return + } + if traffic == nil { + msg := t.I18nBot("tgbot.noResult") + t.SendMsgToTgbot(chatId, msg) + return + } + + output := t.clientInfoMsg(traffic, true, true, true, true, true, true) + + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)), + ), + ) + if len(messageID) > 0 { + t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard) + } else { + t.SendMsgToTgbot(chatId, output, inlineKeyboard) + } +} + +// getCommonClientButtons returns the shared inline keyboard rows for the +// client-first multi-inbound add flow. Per-protocol secrets (UUID, password, +// flow, method) are generated by fillProtocolDefaults on submit, so the bot +// only exposes the universal client fields here. +func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton { + attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs)) + return [][]telego.InlineKeyboardButton{ + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"), + ), + } +} + +// addClient renders the draft message + shared client-first keyboard. +func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) { + inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...) + if len(messageID) > 0 { + t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard) + } else { + t.SendMsgToTgbot(chatId, msg, inlineKeyboard) + } +} diff --git a/internal/web/service/tgbot/tgbot_delete_after_test.go b/internal/web/service/tgbot/tgbot_delete_after_test.go new file mode 100644 index 0000000..4a6ca73 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_delete_after_test.go @@ -0,0 +1,18 @@ +package tgbot + +import "testing" + +func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) { + userStateMgr.reset() + t.Cleanup(userStateMgr.reset) + + const chatID = int64(4242) + userStateMgr.set(chatID, "awaiting_comment") + + tg := &Tgbot{} + tg.deleteMessageAfterDelay(chatID, 1, 0) + + if st, ok := userStateMgr.get(chatID); !ok || st != "awaiting_comment" { + t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment") + } +} diff --git a/internal/web/service/tgbot/tgbot_event.go b/internal/web/service/tgbot/tgbot_event.go new file mode 100644 index 0000000..822dacf --- /dev/null +++ b/internal/web/service/tgbot/tgbot_event.go @@ -0,0 +1,162 @@ +package tgbot + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" +) + +var cachedHostname string + +func getHostname() string { + if cachedHostname != "" { + return cachedHostname + } + h, err := os.Hostname() + if err != nil { + cachedHostname = "unknown" + } else { + cachedHostname = h + } + return cachedHostname +} + +var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute) + +// HandleEvent is the eventbus subscriber callback. It formats incoming events +// as Telegram messages and sends them to all admin chats. +func (t *Tgbot) HandleEvent(e eventbus.Event) { + if !t.isEventEnabled(e.Type) { + return + } + if e.Type != eventbus.EventLoginAttempt { + if !tgEventLimiter.Allow(e.Type, e.Source) { + return + } + } + msg := t.formatEventMessage(e) + if msg != "" { + t.SendMsgToTgbotAdmins(msg) + } +} + +func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool { + events, err := t.settingService.GetTgEnabledEvents() + if err != nil || events == "" { + return false + } + for e := range strings.SplitSeq(events, ",") { + if strings.TrimSpace(e) == string(eventType) { + return true + } + } + return false +} + +func (t *Tgbot) formatEventMessage(e eventbus.Event) string { + host := getHostname() + header := fmt.Sprintf("📡 %s\n", host) + + switch e.Type { + case eventbus.EventOutboundDown: + msg := header + t.I18nBot("tgbot.messages.eventOutboundDown", + "Tag=="+e.Source) + if data, ok := e.Data.(*eventbus.OutboundHealthData); ok { + if data.Error != "" { + msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", + "Error=="+data.Error) + } + if data.Delay > 0 { + msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", + "Delay=="+fmt.Sprintf("%d", data.Delay)) + } + } + return msg + + case eventbus.EventOutboundUp: + msg := header + t.I18nBot("tgbot.messages.eventOutboundUp", + "Tag=="+e.Source) + if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 { + msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", + "Delay=="+fmt.Sprintf("%d", data.Delay)) + } + return msg + + case eventbus.EventXrayCrash: + errStr := "" + if e.Data != nil { + errStr = fmt.Sprint(e.Data) + } + msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash") + if errStr != "" { + msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr) + } + return msg + + case eventbus.EventNodeDown: + msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source) + if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" { + msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError) + } + return msg + + case eventbus.EventNodeUp: + msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source) + if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 { + msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs)) + } + return msg + + case eventbus.EventCPUHigh: + if data, ok := e.Data.(*eventbus.SystemMetricData); ok { + tgCpu, err := t.settingService.GetTgCpu() + if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) { + return "" + } + return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold", + "Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64), + "Threshold=="+strconv.Itoa(tgCpu)) + } + return "" + + case eventbus.EventMemoryHigh: + if data, ok := e.Data.(*eventbus.SystemMetricData); ok { + tgMemory, err := t.settingService.GetTgMemory() + if err != nil || tgMemory <= 0 || data.Percent <= float64(tgMemory) { + return "" + } + return header + "🔴 " + t.I18nBot("tgbot.messages.memoryThreshold", + "Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64), + "Threshold=="+strconv.Itoa(tgMemory)) + } + return "" + + case eventbus.EventLoginAttempt: + if data, ok := e.Data.(*eventbus.LoginEventData); ok { + if data.Status == "success" { + msg := t.I18nBot("tgbot.messages.loginSuccess") + msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) + msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) + msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) + return msg + } + msg := t.I18nBot("tgbot.messages.loginFailed") + msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) + if data.Reason != "" { + msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason) + } + msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) + msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) + return msg + } + return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source) + } + + return "" +} diff --git a/internal/web/service/tgbot/tgbot_inbound.go b/internal/web/service/tgbot/tgbot_inbound.go new file mode 100644 index 0000000..12b1caf --- /dev/null +++ b/internal/web/service/tgbot/tgbot_inbound.go @@ -0,0 +1,306 @@ +package tgbot + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// getInboundUsages retrieves and formats inbound usage information. +func (t *Tgbot) getInboundUsages() string { + var info strings.Builder + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("GetAllInbounds run failed:", err) + info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed")) + return info.String() + } + for _, inbound := range inbounds { + info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)) + info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))) + info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))) + + clients, listErr := t.clientService.ListForInbound(nil, inbound.Id) + if listErr == nil { + fmt.Fprintf(&info, "👥 Clients: %d\r\n", len(clients)) + } + + if inbound.ExpiryTime == 0 { + info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))) + } else { + info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))) + } + info.WriteString("\r\n") + } + return info.String() +} + +// getInbounds creates an inline keyboard with all inbounds. +func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("GetAllInbounds run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + + if len(inbounds) == 0 { + logger.Warning("No inbounds found") + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + + var buttons []telego.InlineKeyboardButton + for _, inbound := range inbounds { + status := "❌" + if inbound.Enable { + status = "✅" + } + callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id)) + buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData)) + } + + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + return keyboard, nil +} + +// getInboundsFor builds an inline keyboard of inbounds for a custom next action. +func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("GetAllInbounds run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + + if len(inbounds) == 0 { + logger.Warning("No inbounds found") + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + + var buttons []telego.InlineKeyboardButton + for _, inbound := range inbounds { + status := "❌" + if inbound.Enable { + status = "✅" + } + callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id)) + buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData)) + } + + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + return keyboard, nil +} + +// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email +func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) { + inbound, err := t.inboundService.GetInbound(inboundID) + if err != nil { + logger.Warning("getInboundClientsFor run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + clients, err := t.inboundService.GetClients(inbound) + var buttons []telego.InlineKeyboardButton + + if err != nil { + logger.Warning("GetInboundClients run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } else { + if len(clients) > 0 { + for _, client := range clients { + buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email))) + } + } else { + return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed")) + } + } + cols := 0 + if len(buttons) < 6 { + cols = 3 + } else { + cols = 2 + } + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + + return keyboard, nil +} + +// getInboundsAddClient creates an inline keyboard for adding clients to inbounds. +func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("GetAllInbounds run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + + if len(inbounds) == 0 { + logger.Warning("No inbounds found") + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + + excludedProtocols := map[model.Protocol]bool{ + model.Tunnel: true, + model.Mixed: true, + model.WireGuard: true, + model.AmneziaWG: true, + model.HTTP: true, + } + + var buttons []telego.InlineKeyboardButton + for _, inbound := range inbounds { + if excludedProtocols[inbound.Protocol] { + continue + } + + status := "❌" + if inbound.Enable { + status = "✅" + } + callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id)) + buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData)) + } + + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + return keyboard, nil +} + +// getInboundsAttachPicker builds a toggle picker over multi-client inbounds +// for the "attach more inbounds to the new client" step. Each row shows the +// current selection state for the inbound; tapping fires +// add_client_toggle_attach which flips it and re-renders. A final +// "Done" button (add_client_attach_done) returns to the field-edit screen. +func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) { + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("GetAllInbounds run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + if len(inbounds) == 0 { + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + excludedProtocols := map[model.Protocol]bool{ + model.Tunnel: true, + model.Mixed: true, + model.WireGuard: true, + model.AmneziaWG: true, + model.HTTP: true, + } + selected := make(map[int]bool, len(receiver_inbound_IDs)) + for _, id := range receiver_inbound_IDs { + selected[id] = true + } + var buttons []telego.InlineKeyboardButton + for _, ib := range inbounds { + if excludedProtocols[ib.Protocol] { + continue + } + mark := "☐" + if selected[ib.Id] { + mark = "✅" + } + label := fmt.Sprintf("%s %s (%s)", mark, ib.Remark, ib.Protocol) + callback := t.encodeQuery(fmt.Sprintf("add_client_toggle_attach %d", ib.Id)) + buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(callback)) + } + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + rows := tu.InlineKeyboardCols(cols, buttons...) + rows = append(rows, tu.InlineKeyboardRow( + tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")), + )) + return tu.InlineKeyboardGrid(rows), nil +} + +// getInboundClients creates an inline keyboard with clients of a specific inbound. +func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) { + inbound, err := t.inboundService.GetInbound(id) + if err != nil { + logger.Warning("getIboundClients run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } + clients, err := t.inboundService.GetClients(inbound) + var buttons []telego.InlineKeyboardButton + + if err != nil { + logger.Warning("GetInboundClients run failed:", err) + return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) + } else { + if len(clients) > 0 { + for _, client := range clients { + buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email))) + } + } else { + return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed")) + } + } + cols := 0 + if len(buttons) < 6 { + cols = 3 + } else { + cols = 2 + } + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + + return keyboard, nil +} + +// searchInbound searches for inbounds by remark and sends the results. +func (t *Tgbot) searchInbound(chatId int64, remark string) { + inbounds, err := t.inboundService.SearchInbounds(remark) + if err != nil { + logger.Warning(err) + msg := t.I18nBot("tgbot.wentWrong") + t.SendMsgToTgbot(chatId, msg) + return + } + if len(inbounds) == 0 { + msg := t.I18nBot("tgbot.noInbounds") + t.SendMsgToTgbot(chatId, msg) + return + } + + for _, inbound := range inbounds { + info := "" + info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark) + info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)) + info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)) + + if inbound.ExpiryTime == 0 { + info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")) + } else { + info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")) + } + t.SendMsgToTgbot(chatId, info) + + if len(inbound.ClientStats) > 0 { + var output strings.Builder + for _, traffic := range inbound.ClientStats { + output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true)) + } + t.SendMsgToTgbot(chatId, output.String()) + } + } +} diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go new file mode 100644 index 0000000..b323733 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_report.go @@ -0,0 +1,509 @@ +package tgbot + +import ( + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// SendReport sends a periodic report to admin chats. +func (t *Tgbot) SendReport() { + runTime, err := t.settingService.GetTgbotRuntime() + if err == nil && len(runTime) > 0 { + msg := "" + msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime) + msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05")) + t.SendMsgToTgbotAdmins(msg) + } + + info := t.sendServerUsage() + t.SendMsgToTgbotAdmins(info) + + t.sendExhaustedToAdmins() + t.notifyExhausted() + + backupEnable, err := t.settingService.GetTgBotBackup() + if err == nil && backupEnable { + t.SendBackupToAdmins() + } +} + +// SendBackupToAdmins sends a database backup to admin chats. +func (t *Tgbot) SendBackupToAdmins() { + if !t.IsRunning() { + return + } + dbData, err := t.serverService.GetDb() + if err != nil { + logger.Error("Error in getting db backup: ", err) + } + dbFilename := t.serverService.BackupFilename("") + for i, adminId := range adminIds { + t.sendBackupData(adminId, dbData, dbFilename) + // Add delay between sends to avoid Telegram rate limits + if i < len(adminIds)-1 { + time.Sleep(1 * time.Second) + } + } +} + +// sendExhaustedToAdmins sends notifications about exhausted clients to admins. +func (t *Tgbot) sendExhaustedToAdmins() { + if !t.IsRunning() { + return + } + for _, adminId := range adminIds { + t.getExhausted(adminId) + } +} + +// getServerUsage retrieves and formats server usage information. +func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string { + info := t.prepareServerUsageInfo() + + keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh")))) + + if len(messageID) > 0 { + t.editMessageTgBot(chatId, messageID[0], info, keyboard) + } else { + t.SendMsgToTgbot(chatId, info, keyboard) + } + + return info +} + +// Send server usage without an inline keyboard +func (t *Tgbot) sendServerUsage() string { + info := t.prepareServerUsageInfo() + return info +} + +// prepareServerUsageInfo prepares the server usage information string. +func (t *Tgbot) prepareServerUsageInfo() string { + // Check if we have cached data first + if cachedStats, found := t.getCachedServerStats(); found { + return cachedStats + } + + info, ipv4, ipv6 := "", "", "" + + // get latest status of server with caching + if cachedStatus, found := t.getCachedStatus(); found { + t.lastStatus = cachedStatus + } else { + t.lastStatus = t.serverService.GetStatus(t.lastStatus) + t.setCachedStatus(t.lastStatus) + } + var onlines []string + if process := service.XrayProcess(); process != nil { + onlines = process.GetOnlineClients() + } + + info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) + info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion()) + info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version)) + + // get ip address + netInterfaces, err := net.Interfaces() + if err != nil { + logger.Error("net.Interfaces failed, err: ", err.Error()) + info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown")) + info += "\r\n" + } else { + for i := range netInterfaces { + if (netInterfaces[i].Flags & net.FlagUp) != 0 { + addrs, _ := netInterfaces[i].Addrs() + + for _, address := range addrs { + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + ipv4 += ipnet.IP.String() + " " + } else if ipnet.IP.To16() != nil && !ipnet.IP.IsLinkLocalUnicast() { + ipv6 += ipnet.IP.String() + " " + } + } + } + } + } + + info += t.I18nBot("tgbot.messages.ipv4", "IPv4=="+ipv4) + info += t.I18nBot("tgbot.messages.ipv6", "IPv6=="+ipv6) + } + + info += t.I18nBot("tgbot.messages.serverUpTime", "UpTime=="+strconv.FormatUint(t.lastStatus.Uptime/86400, 10), "Unit=="+t.I18nBot("tgbot.days")) + info += t.I18nBot("tgbot.messages.serverLoad", "Load1=="+strconv.FormatFloat(t.lastStatus.Loads[0], 'f', 2, 64), "Load2=="+strconv.FormatFloat(t.lastStatus.Loads[1], 'f', 2, 64), "Load3=="+strconv.FormatFloat(t.lastStatus.Loads[2], 'f', 2, 64)) + info += t.I18nBot("tgbot.messages.serverMemory", "Current=="+common.FormatTraffic(int64(t.lastStatus.Mem.Current)), "Total=="+common.FormatTraffic(int64(t.lastStatus.Mem.Total))) + info += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(len(onlines))) + info += t.I18nBot("tgbot.messages.tcpCount", "Count=="+strconv.Itoa(t.lastStatus.TcpCount)) + info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount)) + info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv))) + info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State)) + + // Cache the complete server stats + t.setCachedServerStats(info) + + return info +} + +// UserLoginNotify publishes a login event to the event bus. +func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) { + if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" { + logger.Warning("UserLoginNotify failed, invalid info!") + return + } + + if EventBus == nil { + return + } + + status := "fail" + if attempt.Status == LoginSuccess { + status = "success" + } + + EventBus.Publish(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Source: attempt.IP, + Data: &eventbus.LoginEventData{ + Username: attempt.Username, + IP: attempt.IP, + Time: attempt.Time, + Status: status, + Reason: attempt.Reason, + }, + }) +} + +// getExhausted retrieves and sends information about exhausted clients. +func (t *Tgbot) getExhausted(chatId int64) { + trDiff := int64(0) + exDiff := int64(0) + now := time.Now().Unix() * 1000 + var exhaustedInbounds []model.Inbound + var exhaustedClients []xray.ClientTraffic + var disabledInbounds []model.Inbound + var disabledClients []xray.ClientTraffic + + TrafficThreshold, err := t.settingService.GetTrafficDiff() + if err == nil && TrafficThreshold > 0 { + trDiff = int64(TrafficThreshold) * 1073741824 + } + ExpireThreshold, err := t.settingService.GetExpireDiff() + if err == nil && ExpireThreshold > 0 { + exDiff = int64(ExpireThreshold) * 86400000 + } + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Unable to load Inbounds", err) + } + + seenClients := make(map[string]bool) + for _, inbound := range inbounds { + if inbound.Enable { + if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) || + (inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) { + exhaustedInbounds = append(exhaustedInbounds, *inbound) + } + if len(inbound.ClientStats) > 0 { + for _, client := range inbound.ClientStats { + if seenClients[client.Email] { + continue + } + seenClients[client.Email] = true + if client.Enable { + if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) || + (client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) { + exhaustedClients = append(exhaustedClients, client) + } + } else { + disabledClients = append(disabledClients, client) + } + } + } + } else { + disabledInbounds = append(disabledInbounds, *inbound) + } + } + + // Inbounds + output := "" + output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds")) + output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds))) + output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds))) + + if len(exhaustedInbounds) > 0 { + output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds")) + + for _, inbound := range exhaustedInbounds { + output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark) + output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)) + output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)) + if inbound.ExpiryTime == 0 { + output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")) + } else { + output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")) + } + output += "\r\n" + } + } + + // Clients + exhaustedCC := len(exhaustedClients) + output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")) + output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients))) + output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC)) + + if exhaustedCC > 0 { + output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients")) + var buttons []telego.InlineKeyboardButton + for _, traffic := range exhaustedClients { + output += t.clientInfoMsg(&traffic, true, false, false, true, true, false) + output += "\r\n" + buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email))) + } + cols := 0 + if exhaustedCC < 11 { + cols = 1 + } else { + cols = 2 + } + output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + t.SendMsgToTgbot(chatId, output, keyboard) + } else { + output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + t.SendMsgToTgbot(chatId, output) + } +} + +// notifyExhausted sends notifications for exhausted clients. +func (t *Tgbot) notifyExhausted() { + trDiff := int64(0) + exDiff := int64(0) + now := time.Now().Unix() * 1000 + + TrafficThreshold, err := t.settingService.GetTrafficDiff() + if err == nil && TrafficThreshold > 0 { + trDiff = int64(TrafficThreshold) * 1073741824 + } + ExpireThreshold, err := t.settingService.GetExpireDiff() + if err == nil && ExpireThreshold > 0 { + exDiff = int64(ExpireThreshold) * 86400000 + } + inbounds, err := t.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Unable to load Inbounds", err) + } + + var chatIDsDone []int64 + for _, inbound := range inbounds { + if inbound.Enable { + if len(inbound.ClientStats) > 0 { + clients, err := t.inboundService.GetClients(inbound) + if err == nil { + for _, client := range clients { + if client.TgID != 0 { + chatID := client.TgID + if !int64Contains(chatIDsDone, chatID) && !checkAdmin(chatID) { + var disabledClients []xray.ClientTraffic + var exhaustedClients []xray.ClientTraffic + traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID) + if err == nil && len(traffics) > 0 { + var output strings.Builder + output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))) + for _, traffic := range traffics { + if traffic.Enable { + if (traffic.ExpiryTime > 0 && (traffic.ExpiryTime-now < exDiff)) || + (traffic.Total > 0 && (traffic.Total-(traffic.Up+traffic.Down) < trDiff)) { + exhaustedClients = append(exhaustedClients, *traffic) + } + } else { + disabledClients = append(disabledClients, *traffic) + } + } + if len(exhaustedClients) > 0 { + output.WriteString(t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))) + if len(disabledClients) > 0 { + output.WriteString(t.I18nBot("tgbot.clients")) + output.WriteString(":\r\n") + for _, traffic := range disabledClients { + output.WriteString(" ") + output.WriteString(traffic.Email) + } + output.WriteString("\r\n") + } + output.WriteString("\r\n") + output.WriteString(t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients)))) + for _, traffic := range exhaustedClients { + output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false)) + output.WriteString("\r\n") + } + t.SendMsgToTgbot(chatID, output.String()) + } + chatIDsDone = append(chatIDsDone, chatID) + } + } + } + } + } + } + } + } +} + +// onlineClients retrieves and sends information about online clients. +func (t *Tgbot) onlineClients(chatId int64, messageID ...int) { + process := service.XrayProcess() + if process == nil || !process.IsRunning() { + return + } + + onlines := process.GetOnlineClients() + onlinesCount := len(onlines) + output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount)) + keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh")))) + + if onlinesCount > 0 { + var buttons []telego.InlineKeyboardButton + for _, online := range onlines { + label := online + if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" { + label = online + " - " + inbound.Remark + } + buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online))) + } + cols := 0 + if onlinesCount < 21 { + cols = 2 + } else if onlinesCount < 61 { + cols = 3 + } else { + cols = 4 + } + keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...) + } + + if len(messageID) > 0 { + t.editMessageTgBot(chatId, messageID[0], output, keyboard) + } else { + t.SendMsgToTgbot(chatId, output, keyboard) + } +} + +// sendBackup sends a backup of the database and configuration files. +func (t *Tgbot) sendBackup(chatId int64) { + dbData, err := t.serverService.GetDb() + if err != nil { + logger.Error("Error in getting db backup: ", err) + } + t.sendBackupData(chatId, dbData, t.serverService.BackupFilename("")) +} + +func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) { + output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) + output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05")) + t.SendMsgToTgbot(chatId, output) + + // Send database backup (SQLite file, or a pg_dump archive on PostgreSQL) + if dbData != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + document := tu.Document( + tu.ID(chatId), + tu.FileFromBytes(dbData, dbFilename), + ) + _, err := bot.SendDocument(ctx, document) + cancel() + if err != nil { + logger.Error("Error in uploading backup: ", err) + } + } + + // Small delay between file sends + time.Sleep(500 * time.Millisecond) + + // Send config.json backup + file, err := os.Open(xray.GetConfigPath()) + if err == nil { + defer file.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + document := tu.Document( + tu.ID(chatId), + tu.File(file), + ) + _, err = bot.SendDocument(ctx, document) + if err != nil { + logger.Error("Error in uploading config.json: ", err) + } + } else { + logger.Error("Error in opening config.json file for backup: ", err) + } +} + +// sendBanLogs sends the ban logs to the specified chat. +func (t *Tgbot) sendBanLogs(chatId int64, dt bool) { + if dt { + output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) + output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05")) + t.SendMsgToTgbot(chatId, output) + } + + file, err := os.Open(xray.GetIPLimitBannedPrevLogPath()) + if err == nil { + // Check if the file is non-empty before attempting to upload + fileInfo, _ := file.Stat() + if fileInfo.Size() > 0 { + document := tu.Document( + tu.ID(chatId), + tu.File(file), + ) + _, err = bot.SendDocument(context.Background(), document) + if err != nil { + logger.Error("Error in uploading IPLimitBannedPrevLog: ", err) + } + } else { + logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.") + } + file.Close() + } else { + logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err) + } + + file, err = os.Open(xray.GetIPLimitBannedLogPath()) + if err == nil { + // Check if the file is non-empty before attempting to upload + fileInfo, _ := file.Stat() + if fileInfo.Size() > 0 { + document := tu.Document( + tu.ID(chatId), + tu.File(file), + ) + _, err = bot.SendDocument(context.Background(), document) + if err != nil { + logger.Error("Error in uploading IPLimitBannedLog: ", err) + } + } else { + logger.Warning("IPLimitBannedLog file is empty, not uploading.") + } + file.Close() + } else { + logger.Error("Error in opening IPLimitBannedLog file for backup: ", err) + } +} diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go new file mode 100644 index 0000000..bc45b41 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_router.go @@ -0,0 +1,1492 @@ +package tgbot + +import ( + "context" + "fmt" + "html" + "slices" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + th "github.com/mymmrac/telego/telegohandler" + tu "github.com/mymmrac/telego/telegoutil" +) + +// OnReceive starts the message receiving loop for the Telegram bot. +func (t *Tgbot) OnReceive() { + params := telego.GetUpdatesParams{ + Timeout: 20, // Reduced timeout to detect connection issues faster + } + // Strict singleton: never start a second long-polling loop. + tgBotMutex.Lock() + if botCancel != nil || isRunning { + tgBotMutex.Unlock() + logger.Warning("TgBot OnReceive called while already running; ignoring.") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + botCancel = cancel + isRunning = true + // Add to WaitGroup before releasing the lock so StopBot() can't return + // before this receiver goroutine is accounted for. + botWG.Add(1) + tgBotMutex.Unlock() + + // Get updates channel using the context with shorter timeout for better error recovery + updates, _ := bot.UpdatesViaLongPolling(ctx, ¶ms) + go func() { + defer botWG.Done() + h, _ := th.NewBotHandler(bot, updates) + tgBotMutex.Lock() + botHandler = h + tgBotMutex.Unlock() + + h.HandleMessage(func(ctx *th.Context, message telego.Message) error { + userStateMgr.clear(message.Chat.ID) + t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove()) + return nil + }, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard"))) + + h.HandleMessage(func(ctx *th.Context, message telego.Message) error { + if !t.isCommandForCurrentBot(&message) { + return nil + } + + // Use goroutine with worker pool for concurrent command processing + go func() { + messageWorkerPool <- struct{}{} // Acquire worker + defer func() { <-messageWorkerPool }() // Release worker + + userStateMgr.clear(message.Chat.ID) + t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) + }() + return nil + }, th.AnyCommand()) + + h.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error { + // Use goroutine with worker pool for concurrent callback processing + go func() { + messageWorkerPool <- struct{}{} // Acquire worker + defer func() { <-messageWorkerPool }() // Release worker + + userStateMgr.clear(query.Message.GetChat().ID) + t.answerCallback(&query, checkAdmin(query.From.ID)) + }() + return nil + }, th.AnyCallbackQueryWithMessage()) + + h.HandleMessage(func(ctx *th.Context, message telego.Message) error { + userStateMgr.maybePrune(time.Hour) + if userState, exists := userStateMgr.get(message.Chat.ID); exists { + switch userState { + case "awaiting_email": + if client_Email == strings.TrimSpace(message.Text) { + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + userStateMgr.clear(message.Chat.ID) + return nil + } + + client_Email = strings.TrimSpace(message.Text) + if t.isSingleWord(client_Email) { + userStateMgr.set(message.Chat.ID, "awaiting_email") + + cancel_btn_markup := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), + ), + ) + + t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) + } else { + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove()) + userStateMgr.clear(message.Chat.ID) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + } + case "awaiting_comment": + if client_Comment == strings.TrimSpace(message.Text) { + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + userStateMgr.clear(message.Chat.ID) + return nil + } + + client_Comment = strings.TrimSpace(message.Text) + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove()) + userStateMgr.clear(message.Chat.ID) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + case "awaiting_tg_id": + input := strings.TrimSpace(message.Text) + if input == "" || input == "-" || strings.EqualFold(input, "none") { + client_TgID = "" + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + userStateMgr.clear(message.Chat.ID) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + return nil + } + if _, err := strconv.ParseInt(input, 10, 64); err != nil { + cancel_btn_markup := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), + ), + ) + t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup) + return nil + } + client_TgID = input + t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.userSaved"), 3, tu.ReplyKeyboardRemove()) + userStateMgr.clear(message.Chat.ID) + t.addClient(message.Chat.ID, t.BuildClientDraftMessage()) + } + } else { + if message.UsersShared != nil { + if checkAdmin(message.From.ID) { + for _, sharedUser := range message.UsersShared.Users { + userID := sharedUser.UserID + needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID) + if needRestart { + t.xrayService.SetToNeedRestart() + } + output := "" + if err != nil { + output += t.I18nBot("tgbot.messages.selectUserFailed") + } else { + output += t.I18nBot("tgbot.messages.userSaved") + } + t.SendMsgToTgbot(message.Chat.ID, output, tu.ReplyKeyboardRemove()) + } + } else { + t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.noResult"), tu.ReplyKeyboardRemove()) + } + } + } + return nil + }, th.AnyMessage()) + + _ = h.Start() + }() +} + +// answerCommand processes incoming command messages from Telegram users. +func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin bool) { + msg, onlyMessage := "", false + + command, _, commandArgs := tu.ParseCommand(message.Text) + + // Helper function to handle unknown commands. + handleUnknownCommand := func() { + msg += t.I18nBot("tgbot.commands.unknown") + } + + // Handle the command. + switch command { + case "help": + msg += t.I18nBot("tgbot.commands.help") + msg += t.I18nBot("tgbot.commands.pleaseChoose") + case "start": + msg += t.I18nBot("tgbot.commands.start", "Firstname=="+html.EscapeString(message.From.FirstName)) + if isAdmin { + msg += t.I18nBot("tgbot.commands.welcome", "Hostname=="+hostname) + } + msg += "\n\n" + t.I18nBot("tgbot.commands.pleaseChoose") + case "status": + onlyMessage = true + msg += t.I18nBot("tgbot.commands.status") + case "id": + onlyMessage = true + msg += t.I18nBot("tgbot.commands.getID", "ID=="+strconv.FormatInt(message.From.ID, 10)) + case "usage": + onlyMessage = true + if len(commandArgs) > 0 { + if isAdmin { + t.searchClient(chatId, commandArgs[0]) + } else { + t.getClientUsage(chatId, message.From.ID, commandArgs[0]) + } + } else { + msg += t.I18nBot("tgbot.commands.usage") + } + case "inbound": + onlyMessage = true + if isAdmin && len(commandArgs) > 0 { + t.searchInbound(chatId, commandArgs[0]) + } else { + handleUnknownCommand() + } + case "restart": + onlyMessage = true + if isAdmin { + if len(commandArgs) == 0 { + if t.xrayService.IsXrayRunning() { + err := t.xrayService.RestartXray(true) + if err != nil { + msg += t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error()) + } else { + msg += t.I18nBot("tgbot.commands.restartSuccess") + } + } else { + msg += t.I18nBot("tgbot.commands.xrayNotRunning") + } + } else { + handleUnknownCommand() + msg += t.I18nBot("tgbot.commands.restartUsage") + } + } else { + handleUnknownCommand() + } + case "clearall": + onlyMessage = true + if isAdmin { + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("reset_all_traffics_cancel")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_all_traffics_c")), + ), + ) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.AreYouSure"), inlineKeyboard) + } else { + handleUnknownCommand() + } + default: + handleUnknownCommand() + } + + if msg != "" { + t.sendResponse(chatId, msg, onlyMessage, isAdmin) + } +} + +func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool { + return isCommandForBot(message.Text, botUsername()) +} + +func botUsername() string { + if bot == nil { + return "" + } + return bot.Username() +} + +func isCommandForBot(text string, username string) bool { + _, commandUsername, _ := tu.ParseCommand(text) + return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username) +} + +// answerCallback processes callback queries from inline keyboards. +func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool) { + chatId := callbackQuery.Message.GetChat().ID + + if isAdmin { + // get query from hash storage + decodedQuery, err := t.decodeQuery(callbackQuery.Data) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noQuery")) + return + } + dataArray := strings.Split(decodedQuery, " ") + + if len(dataArray) >= 2 && len(dataArray[1]) > 0 { + email := dataArray[1] + switch dataArray[0] { + case "get_clients_for_sub": + inboundId := dataArray[1] + inboundIdInt, err := strconv.Atoi(inboundId) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_sub_links") + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + inbound, _ := t.inboundService.GetInbound(inboundIdInt) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) + case "get_clients_for_individual": + inboundId := dataArray[1] + inboundIdInt, err := strconv.Atoi(inboundId) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_individual_links") + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + inbound, _ := t.inboundService.GetInbound(inboundIdInt) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) + case "get_clients_for_qr": + inboundId := dataArray[1] + inboundIdInt, err := strconv.Atoi(inboundId) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + clientsKB, err := t.getInboundClientsFor(inboundIdInt, "client_qr_links") + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + inbound, _ := t.inboundService.GetInbound(inboundIdInt) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clientsKB) + case "client_sub_links": + t.sendClientSubLinks(chatId, email) + return + case "client_individual_links": + t.sendClientIndividualLinks(chatId, email) + return + case "client_qr_links": + t.sendClientQRLinks(chatId, email) + return + case "client_get_usage": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.messages.email", "Email=="+email)) + t.searchClient(chatId, email) + case "client_refresh": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.clientRefreshSuccess", "Email=="+email)) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "client_cancel": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "ips_refresh": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.IpRefreshSuccess", "Email=="+email)) + t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) + case "ips_cancel": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) + t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) + case "tgid_refresh": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.TGIdRefreshSuccess", "Email=="+email)) + t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) + case "tgid_cancel": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+email)) + t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) + case "reset_traffic": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic_c "+email)), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "reset_traffic_c": + err := t.inboundService.ResetClientTrafficByEmail(email) + if err == nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetTrafficSuccess", "Email=="+email)) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + } else { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + } + case "limit_traffic": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 0")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" 0")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 1")), + tu.InlineKeyboardButton("5 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 5")), + tu.InlineKeyboardButton("10 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 10")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("20 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 20")), + tu.InlineKeyboardButton("30 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 30")), + tu.InlineKeyboardButton("40 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 40")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("50 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 50")), + tu.InlineKeyboardButton("60 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 60")), + tu.InlineKeyboardButton("80 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 80")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("100 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 100")), + tu.InlineKeyboardButton("150 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 150")), + tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" 200")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "limit_traffic_c": + if len(dataArray) == 3 { + limitTraffic, err := strconv.Atoi(dataArray[2]) + if err == nil { + needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic) + if needRestart { + t.xrayService.SetToNeedRestart() + } + if err == nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.setTrafficLimitSuccess", "Email=="+email)) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + return + } + } + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "limit_traffic_in": + if len(dataArray) >= 3 { + oldInputNumber, err := strconv.Atoi(dataArray[2]) + inputNumber := oldInputNumber + if err == nil { + if len(dataArray) == 4 { + num, err := strconv.Atoi(dataArray[3]) + if err == nil { + inputNumber = updateNumericInput(inputNumber, num) + } + if inputNumber == oldInputNumber { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + return + } + if inputNumber >= 999999 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + } + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("limit_traffic_c "+email+" "+strconv.Itoa(inputNumber))), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 2")), + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 3")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 4")), + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 6")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 7")), + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 9")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" -2")), + tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" 0")), + tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("limit_traffic_in "+email+" "+strconv.Itoa(inputNumber)+" -1")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + return + } + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "add_client_limit_traffic_c": + limitTraffic, _ := strconv.ParseInt(dataArray[1], 10, 64) + client_TotalGB = limitTraffic * 1024 * 1024 * 1024 + messageId := callbackQuery.Message.GetMessageID() + message_text := t.BuildClientDraftMessage() + + t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + case "add_client_limit_traffic_in": + if len(dataArray) >= 2 { + oldInputNumber, err := strconv.Atoi(dataArray[1]) + inputNumber := oldInputNumber + if err == nil { + if len(dataArray) == 3 { + num, err := strconv.Atoi(dataArray[2]) + if err == nil { + inputNumber = updateNumericInput(inputNumber, num) + } + if inputNumber == oldInputNumber { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + return + } + if inputNumber >= 999999 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + } + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_limit_traffic_c "+strconv.Itoa(inputNumber))), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 2")), + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 3")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 4")), + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 6")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 7")), + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 9")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" -2")), + tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" 0")), + tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_limit_traffic_in "+strconv.Itoa(inputNumber)+" -1")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + return + } + } + case "reset_exp": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 0")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("reset_exp_in "+email+" 0")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 7")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 10")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 14")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 20")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 30")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 90")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 180")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" 365")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "reset_exp_c": + if len(dataArray) == 3 { + days, err := strconv.ParseInt(dataArray[2], 10, 64) + if err == nil { + var date int64 + if days > 0 { + traffic, err := t.inboundService.GetClientTrafficByEmail(email) + if err != nil { + logger.Warning(err) + msg := t.I18nBot("tgbot.wentWrong") + t.SendMsgToTgbot(chatId, msg) + return + } + if traffic == nil { + msg := t.I18nBot("tgbot.noResult") + t.SendMsgToTgbot(chatId, msg) + return + } + + if traffic.ExpiryTime > 0 { + if traffic.ExpiryTime-time.Now().Unix()*1000 < 0 { + date = -(days * 24 * 60 * 60000) + } else { + date = traffic.ExpiryTime + days*24*60*60000 + } + } else { + date = traffic.ExpiryTime - days*24*60*60000 + } + + } + needRestart, err := t.clientService.ResetClientExpiryTimeByEmail(&t.inboundService, email, date) + if needRestart { + t.xrayService.SetToNeedRestart() + } + if err == nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.expireResetSuccess", "Email=="+email)) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + return + } + } + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "reset_exp_in": + if len(dataArray) >= 3 { + oldInputNumber, err := strconv.Atoi(dataArray[2]) + inputNumber := oldInputNumber + if err == nil { + if len(dataArray) == 4 { + num, err := strconv.Atoi(dataArray[3]) + if err == nil { + inputNumber = updateNumericInput(inputNumber, num) + } + if inputNumber == oldInputNumber { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + return + } + if inputNumber >= 999999 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + } + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("reset_exp_c "+email+" "+strconv.Itoa(inputNumber))), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 2")), + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 3")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 4")), + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 6")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 7")), + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 9")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" -2")), + tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" 0")), + tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("reset_exp_in "+email+" "+strconv.Itoa(inputNumber)+" -1")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + return + } + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "add_client_reset_exp_c": + client_ExpiryTime = 0 + days, _ := strconv.ParseInt(dataArray[1], 10, 64) + var date int64 + if client_ExpiryTime > 0 { + if client_ExpiryTime-time.Now().Unix()*1000 < 0 { + date = -(days * 24 * 60 * 60000) + } else { + date = client_ExpiryTime + days*24*60*60000 + } + } else { + date = client_ExpiryTime - days*24*60*60000 + } + client_ExpiryTime = date + + messageId := callbackQuery.Message.GetMessageID() + message_text := t.BuildClientDraftMessage() + + t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + case "add_client_reset_exp_in": + if len(dataArray) >= 2 { + oldInputNumber, err := strconv.Atoi(dataArray[1]) + inputNumber := oldInputNumber + if err == nil { + if len(dataArray) == 3 { + num, err := strconv.Atoi(dataArray[2]) + if err == nil { + inputNumber = updateNumericInput(inputNumber, num) + } + if inputNumber == oldInputNumber { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + return + } + if inputNumber >= 999999 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + } + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumberAdd", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_reset_exp_c "+strconv.Itoa(inputNumber))), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 2")), + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 3")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 4")), + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 6")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 7")), + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 9")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" -2")), + tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" 0")), + tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_reset_exp_in "+strconv.Itoa(inputNumber)+" -1")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + return + } + } + case "ip_limit": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelIpLimit")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 0")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("ip_limit_in "+email+" 0")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 2")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 3")), + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 4")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 6")), + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 7")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 9")), + tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("ip_limit_c "+email+" 10")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "ip_limit_c": + if len(dataArray) == 3 { + count, err := strconv.Atoi(dataArray[2]) + if err == nil { + needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count) + if needRestart { + t.xrayService.SetToNeedRestart() + } + if err == nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetIpSuccess", "Email=="+email, "Count=="+strconv.Itoa(count))) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + return + } + } + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "ip_limit_in": + if len(dataArray) >= 3 { + oldInputNumber, err := strconv.Atoi(dataArray[2]) + inputNumber := oldInputNumber + if err == nil { + if len(dataArray) == 4 { + num, err := strconv.Atoi(dataArray[3]) + if err == nil { + inputNumber = updateNumericInput(inputNumber, num) + } + if inputNumber == oldInputNumber { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + return + } + if inputNumber >= 999999 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + } + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("ip_limit_c "+email+" "+strconv.Itoa(inputNumber))), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 2")), + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 3")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 4")), + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 6")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 7")), + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 9")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" -2")), + tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" 0")), + tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("ip_limit_in "+email+" "+strconv.Itoa(inputNumber)+" -1")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + return + } + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + case "add_client_ip_limit_c": + if len(dataArray) == 2 { + count, _ := strconv.Atoi(dataArray[1]) + client_LimitIP = count + } + + messageId := callbackQuery.Message.GetMessageID() + message_text := t.BuildClientDraftMessage() + + t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + case "add_client_ip_limit_in": + if len(dataArray) >= 2 { + oldInputNumber, err := strconv.Atoi(dataArray[1]) + inputNumber := oldInputNumber + if err == nil { + if len(dataArray) == 3 { + num, err := strconv.Atoi(dataArray[2]) + if err == nil { + inputNumber = updateNumericInput(inputNumber, num) + } + if inputNumber == oldInputNumber { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + return + } + if inputNumber >= 999999 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + } + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_ip_limit")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmNumber", "Num=="+strconv.Itoa(inputNumber))).WithCallbackData(t.encodeQuery("add_client_ip_limit_c "+strconv.Itoa(inputNumber))), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 2")), + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 3")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 4")), + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 6")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 7")), + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 9")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("🔄").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" -2")), + tu.InlineKeyboardButton("0").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" 0")), + tu.InlineKeyboardButton("⬅️").WithCallbackData(t.encodeQuery("add_client_ip_limit_in "+strconv.Itoa(inputNumber)+" -1")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + return + } + } + case "clear_ips": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("ips_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmClearIps")).WithCallbackData(t.encodeQuery("clear_ips_c "+email)), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "clear_ips_c": + err := t.inboundService.ClearClientIps(email) + if err == nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.clearIpSuccess", "Email=="+email)) + t.searchClientIps(chatId, email, callbackQuery.Message.GetMessageID()) + } else { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + } + case "ip_log": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getIpLog", "Email=="+email)) + t.searchClientIps(chatId, email) + case "tg_user": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getUserInfo", "Email=="+email)) + t.clientTelegramUserInfo(chatId, email) + case "tgid_remove": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("tgid_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmRemoveTGUser")).WithCallbackData(t.encodeQuery("tgid_remove_c "+email)), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "tgid_remove_c": + traffic, err := t.inboundService.GetClientTrafficByEmail(email) + if err != nil || traffic == nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + return + } + needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, traffic.Id, EmptyTelegramUserID) + if needRestart { + t.xrayService.SetToNeedRestart() + } + if err == nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.removedTGUserSuccess", "Email=="+email)) + t.clientTelegramUserInfo(chatId, email, callbackQuery.Message.GetMessageID()) + } else { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + } + case "toggle_enable": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("client_cancel "+email)), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmToggle")).WithCallbackData(t.encodeQuery("toggle_enable_c "+email)), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "toggle_enable_c": + enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email) + if needRestart { + t.xrayService.SetToNeedRestart() + } + if err == nil { + if enabled { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.enableSuccess", "Email=="+email)) + } else { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.disableSuccess", "Email=="+email)) + } + t.searchClient(chatId, email, callbackQuery.Message.GetMessageID()) + } else { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation")) + } + case "get_clients": + inboundId := dataArray[1] + inboundIdInt, err := strconv.Atoi(inboundId) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + inbound, err := t.inboundService.GetInbound(inboundIdInt) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + clients, err := t.getInboundClients(inboundIdInt) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseClient", "Inbound=="+inbound.Remark), clients) + case "add_client_to": + client_Email = t.randomLowerAndNum(8) + client_LimitIP = 0 + client_TotalGB = 0 + client_ExpiryTime = 0 + client_Enable = true + client_TgID = "" + client_SubID = t.randomLowerAndNum(16) + client_Comment = "" + client_Reset = 0 + + inboundId := dataArray[1] + inboundIdInt, err := strconv.Atoi(inboundId) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + receiver_inbound_ID = inboundIdInt + receiver_inbound_IDs = []int{inboundIdInt} + t.addClient(callbackQuery.Message.GetChat().ID, t.BuildClientDraftMessage()) + case "add_client_toggle_attach": + inboundIdStr := dataArray[1] + inboundIdInt, err := strconv.Atoi(inboundIdStr) + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + found := -1 + for i, id := range receiver_inbound_IDs { + if id == inboundIdInt { + found = i + break + } + } + if found >= 0 { + receiver_inbound_IDs = append(receiver_inbound_IDs[:found], receiver_inbound_IDs[found+1:]...) + } else { + receiver_inbound_IDs = append(receiver_inbound_IDs, inboundIdInt) + } + picker, err := t.getInboundsAttachPicker() + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.editMessageCallbackTgBot(callbackQuery.Message.GetChat().ID, callbackQuery.Message.GetMessageID(), picker) + } + return + } else { + switch callbackQuery.Data { + case "get_inbounds": + inbounds, err := t.getInbounds() + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.allClients")) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) + case "admin_client_sub_links": + inbounds, err := t.getInboundsFor("get_clients_for_sub") + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) + case "admin_client_individual_links": + inbounds, err := t.getInboundsFor("get_clients_for_individual") + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) + case "admin_client_qr_links": + inbounds, err := t.getInboundsFor("get_clients_for_qr") + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) + } + } + } + + if !isAdmin && !isClientSelfCallback(callbackQuery.Data) { + return + } + + switch callbackQuery.Data { + case "get_usage": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.serverUsage")) + t.getServerUsage(chatId) + case "usage_refresh": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + t.getServerUsage(chatId, callbackQuery.Message.GetMessageID()) + case "inbounds": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.getInbounds")) + t.SendMsgToTgbot(chatId, t.getInboundUsages()) + case "deplete_soon": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.depleteSoon")) + t.getExhausted(chatId) + case "get_backup": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.dbBackup")) + t.sendBackup(chatId) + case "get_banlogs": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.getBanLogs")) + t.sendBanLogs(chatId, true) + case "client_traffic": + tgUserID := callbackQuery.From.ID + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.clientUsage")) + t.getClientUsage(chatId, tgUserID) + case "client_commands": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands")) + case "client_sub_links": + // show user's own clients to choose one for sub links + tgUserID := callbackQuery.From.ID + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + // fallback to message + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + if len(traffics) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + var buttons []telego.InlineKeyboardButton + for _, tr := range traffics { + buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_sub_links "+tr.Email))) + } + cols := 1 + if len(buttons) >= 6 { + cols = 2 + } + keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...)) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard) + case "client_individual_links": + // show user's clients to choose for individual links + tgUserID := callbackQuery.From.ID + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) + return + } + if len(traffics) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + var buttons2 []telego.InlineKeyboardButton + for _, tr := range traffics { + buttons2 = append(buttons2, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_individual_links "+tr.Email))) + } + cols2 := 1 + if len(buttons2) >= 6 { + cols2 = 2 + } + keyboard2 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols2, buttons2...)) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard2) + case "client_qr_links": + // show user's clients to choose for QR codes + tgUserID := callbackQuery.From.ID + traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOccurred")+"\r\n"+err.Error()) + return + } + if len(traffics) == 0 { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10))) + return + } + var buttons3 []telego.InlineKeyboardButton + for _, tr := range traffics { + buttons3 = append(buttons3, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_qr_links "+tr.Email))) + } + cols3 := 1 + if len(buttons3) >= 6 { + cols3 = 2 + } + keyboard3 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols3, buttons3...)) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard3) + case "onlines": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines")) + t.onlineClients(chatId) + case "onlines_refresh": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) + t.onlineClients(chatId, callbackQuery.Message.GetMessageID()) + case "commands": + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands")) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpAdminCommands")) + case "add_client": + client_Email = t.randomLowerAndNum(8) + client_LimitIP = 0 + client_TotalGB = 0 + client_ExpiryTime = 0 + client_Enable = true + client_TgID = "" + client_SubID = t.randomLowerAndNum(16) + client_Comment = "" + client_Reset = 0 + + inbounds, err := t.getInboundsAddClient() + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.addClient")) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.chooseInbound"), inbounds) + case "add_client_ch_default_email": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + userStateMgr.set(chatId, "awaiting_email") + cancel_btn_markup := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), + ), + ) + prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+client_Email) + t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) + case "add_client_ch_default_comment": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + userStateMgr.set(chatId, "awaiting_comment") + cancel_btn_markup := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), + ), + ) + prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment) + t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup) + case "add_client_ch_default_tg_id": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + userStateMgr.set(chatId, "awaiting_tg_id") + cancel_btn_markup := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"), + ), + ) + current := client_TgID + if current == "" { + current = "—" + } + t.SendMsgToTgbot(chatId, fmt.Sprintf("Send the Telegram user id (numeric) to attach to this client, or send `-` / `none` to clear.\nCurrent: `%s`", current), cancel_btn_markup) + case "add_client_ch_default_traffic": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 0")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_limit_traffic_in 0")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 1")), + tu.InlineKeyboardButton("5 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 5")), + tu.InlineKeyboardButton("10 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 10")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("20 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 20")), + tu.InlineKeyboardButton("30 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 30")), + tu.InlineKeyboardButton("40 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 40")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("50 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 50")), + tu.InlineKeyboardButton("60 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 60")), + tu.InlineKeyboardButton("80 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 80")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("100 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 100")), + tu.InlineKeyboardButton("150 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 150")), + tu.InlineKeyboardButton("200 GB").WithCallbackData(t.encodeQuery("add_client_limit_traffic_c 200")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "add_client_ch_default_exp": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 0")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_reset_exp_in 0")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 7 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 7")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 10 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 10")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 14 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 14")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 20 "+t.I18nBot("tgbot.days")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 20")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 1 "+t.I18nBot("tgbot.month")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 30")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 3 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 90")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 6 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 180")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.add")+" 12 "+t.I18nBot("tgbot.months")).WithCallbackData(t.encodeQuery("add_client_reset_exp_c 365")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "add_client_ch_default_ip_limit": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_ip_limit")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.unlimited")).WithCallbackData(t.encodeQuery("add_client_ip_limit_c 0")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.custom")).WithCallbackData(t.encodeQuery("add_client_ip_limit_in 0")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("1").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 1")), + tu.InlineKeyboardButton("2").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 2")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("3").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 3")), + tu.InlineKeyboardButton("4").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 4")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("5").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 5")), + tu.InlineKeyboardButton("6").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 6")), + tu.InlineKeyboardButton("7").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 7")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton("8").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 8")), + tu.InlineKeyboardButton("9").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 9")), + tu.InlineKeyboardButton("10").WithCallbackData(t.encodeQuery("add_client_ip_limit_c 10")), + ), + ) + t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard) + case "add_client_default_info": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove()) + userStateMgr.clear(chatId) + t.addClient(chatId, t.BuildClientDraftMessage()) + case "add_client_cancel": + userStateMgr.clear(chatId) + receiver_inbound_ID = 0 + receiver_inbound_IDs = nil + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.cancel"), 3, tu.ReplyKeyboardRemove()) + case "add_client_default_traffic_exp": + messageId := callbackQuery.Message.GetMessageID() + message_text := t.BuildClientDraftMessage() + t.addClient(chatId, message_text, messageId) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) + case "add_client_default_ip_limit": + messageId := callbackQuery.Message.GetMessageID() + message_text := t.BuildClientDraftMessage() + t.addClient(chatId, message_text, messageId) + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) + case "add_client_attach_more": + picker, err := t.getInboundsAttachPicker() + if err != nil { + t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error()) + return + } + t.SendMsgToTgbot(chatId, "Pick inbound(s) to attach:", picker) + case "add_client_attach_done": + if receiver_inbound_ID == 0 && len(receiver_inbound_IDs) > 0 { + receiver_inbound_ID = receiver_inbound_IDs[0] + } + if receiver_inbound_ID == 0 { + t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.getInboundsFailed")) + return + } + message_text := t.BuildClientDraftMessage() + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + t.addClient(chatId, message_text) + case "add_client_submit_disable": + client_Enable = false + _, err := t.SubmitAddClient() + if err != nil { + errorMessage := fmt.Sprintf("%v", err) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove()) + } else { + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) + t.sendClientIndividualLinks(chatId, client_Email) + t.sendClientQRLinks(chatId, client_Email) + receiver_inbound_ID = 0 + receiver_inbound_IDs = nil + } + case "add_client_submit_enable": + client_Enable = true + _, err := t.SubmitAddClient() + if err != nil { + errorMessage := fmt.Sprintf("%v", err) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.error_add_client", "error=="+errorMessage), tu.ReplyKeyboardRemove()) + } else { + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove()) + t.sendClientIndividualLinks(chatId, client_Email) + t.sendClientQRLinks(chatId, client_Email) + receiver_inbound_ID = 0 + receiver_inbound_IDs = nil + } + case "reset_all_traffics_cancel": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + t.SendMsgToTgbotDeleteAfter(chatId, t.I18nBot("tgbot.messages.cancel"), 1, tu.ReplyKeyboardRemove()) + case "reset_all_traffics": + inlineKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancelReset")).WithCallbackData(t.encodeQuery("reset_all_traffics_cancel")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.confirmResetTraffic")).WithCallbackData(t.encodeQuery("reset_all_traffics_c")), + ), + ) + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.AreYouSure"), inlineKeyboard) + case "reset_all_traffics_c": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + emails, err := t.inboundService.GetAllEmails() + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) + return + } + + for _, email := range emails { + err := t.inboundService.ResetClientTrafficByEmail(email) + if err == nil { + msg := t.I18nBot("tgbot.messages.SuccessResetTraffic", "ClientEmail=="+email) + t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) + } else { + msg := t.I18nBot("tgbot.messages.FailedResetTraffic", "ClientEmail=="+email, "ErrorMessage=="+err.Error()) + t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) + } + } + + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.FinishProcess"), tu.ReplyKeyboardRemove()) + case "get_sorted_traffic_usage_report": + t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID()) + emails, err := t.inboundService.GetAllEmails() + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) + return + } + valid_emails, extra_emails, err := t.inboundService.FilterAndSortClientEmails(emails) + if err != nil { + t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) + return + } + + for _, valid_emails := range valid_emails { + traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails) + if err != nil { + logger.Warning(err) + msg := t.I18nBot("tgbot.wentWrong") + t.SendMsgToTgbot(chatId, msg) + continue + } + if traffic == nil { + msg := t.I18nBot("tgbot.noResult") + t.SendMsgToTgbot(chatId, msg) + continue + } + + output := t.clientInfoMsg(traffic, false, false, false, false, true, false) + t.SendMsgToTgbot(chatId, output, tu.ReplyKeyboardRemove()) + } + for _, extra_emails := range extra_emails { + msg := fmt.Sprintf("📧 %s\n%s", extra_emails, t.I18nBot("tgbot.noResult")) + t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) + + } + default: + if after, ok := strings.CutPrefix(callbackQuery.Data, "client_sub_links "); ok { + email := after + t.sendClientSubLinks(chatId, email) + return + } + if after, ok := strings.CutPrefix(callbackQuery.Data, "client_individual_links "); ok { + email := after + t.sendClientIndividualLinks(chatId, email) + return + } + if after, ok := strings.CutPrefix(callbackQuery.Data, "client_qr_links "); ok { + email := after + t.sendClientQRLinks(chatId, email) + return + } + } +} + +// checkAdmin checks if the given Telegram ID is an admin. +func checkAdmin(tgId int64) bool { + return slices.Contains(adminIds, tgId) +} + +// isClientSelfCallback reports whether a callback is one of the per-user client +// actions that resolve their own data from the caller's Telegram id, and so are +// safe to run for a non-admin. Every other callback is admin-only (default-deny). +func isClientSelfCallback(data string) bool { + switch data { + case "client_traffic", "client_commands", "client_sub_links", + "client_individual_links", "client_qr_links": + return true + } + return strings.HasPrefix(data, "client_sub_links ") || + strings.HasPrefix(data, "client_individual_links ") || + strings.HasPrefix(data, "client_qr_links ") +} diff --git a/internal/web/service/tgbot/tgbot_send.go b/internal/web/service/tgbot/tgbot_send.go new file mode 100644 index 0000000..b86a13b --- /dev/null +++ b/internal/web/service/tgbot/tgbot_send.go @@ -0,0 +1,296 @@ +package tgbot + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" +) + +// sendResponse sends the response message based on the onlyMessage flag. +func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) { + if onlyMessage { + t.SendMsgToTgbot(chatId, msg) + } else { + t.SendAnswer(chatId, msg, isAdmin) + } +} + +// SendAnswer sends a response message with an inline keyboard to the specified chat. +func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) { + numericKeyboard := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")), + tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")), + tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")), + ), + // TODOOOOOOOOOOOOOO: Add restart button here. + ) + numericKeyboardClient := tu.InlineKeyboard( + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")), + tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")), + tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")), + ), + tu.InlineKeyboardRow( + tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")), + ), + ) + + var ReplyMarkup telego.ReplyMarkup + if isAdmin { + ReplyMarkup = numericKeyboard + } else { + ReplyMarkup = numericKeyboardClient + } + t.SendMsgToTgbot(chatId, msg, ReplyMarkup) +} + +const telegramPageLimit = 2000 + +func pageMessage(message string, limit int) []string { + if len(message) <= limit { + return []string{message} + } + + pages := make([]string, 0) + for _, block := range strings.Split(message, "\r\n\r\n") { + for _, page := range splitMessageLines(block, limit) { + last := len(pages) - 1 + if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit { + pages[last] += "\r\n\r\n" + page + continue + } + pages = append(pages, page) + } + } + if len(pages) > 0 && strings.TrimSpace(pages[len(pages)-1]) == "" { + pages = pages[:len(pages)-1] + } + return pages +} + +func splitMessageLines(block string, limit int) []string { + if len(block) <= limit { + return []string{block} + } + + lines := strings.Split(block, "\r\n") + pages := []string{lines[0]} + for _, line := range lines[1:] { + last := len(pages) - 1 + if len(pages[last])+len("\r\n")+len(line) > limit { + pages = append(pages, line) + continue + } + pages[last] += "\r\n" + line + } + return pages +} + +// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup. +func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) { + if !isRunning { + return + } + + if msg == "" { + logger.Info("[tgbot] message is empty!") + return + } + + allMessages := pageMessage(msg, telegramPageLimit) + for n, message := range allMessages { + params := telego.SendMessageParams{ + ChatID: tu.ID(chatId), + Text: message, + ParseMode: "HTML", + } + // only add replyMarkup to last message + if len(replyMarkup) > 0 && n == (len(allMessages)-1) { + params.ReplyMarkup = replyMarkup[0] + } + + // Retry logic with exponential backoff for connection errors + maxRetries := 3 + for attempt := range maxRetries { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + _, err := bot.SendMessage(ctx, ¶ms) + cancel() + + if err == nil { + break // Success + } + + // Check if error is a connection error + errStr := err.Error() + isConnectionError := strings.Contains(errStr, "connection") || + strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "closed") + + if isConnectionError && attempt < maxRetries-1 { + // Exponential backoff: 1s, 2s, 4s + backoff := time.Duration(1< 0 { + for _, adminId := range adminIds { + t.SendMsgToTgbot(adminId, msg, replyMarkup[0]) + } + } else { + for _, adminId := range adminIds { + t.SendMsgToTgbot(adminId, msg) + } + } +} + +// sendCallbackAnswerTgBot answers a callback query with a message. +func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) { + params := telego.AnswerCallbackQueryParams{ + CallbackQueryID: id, + Text: message, + } + if err := bot.AnswerCallbackQuery(context.Background(), ¶ms); err != nil { + logger.Warning(err) + } +} + +// editMessageCallbackTgBot edits the reply markup of a message. +func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) { + params := telego.EditMessageReplyMarkupParams{ + ChatID: tu.ID(chatId), + MessageID: messageID, + ReplyMarkup: inlineKeyboard, + } + if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil { + logger.Warning(err) + } +} + +// editMessageTgBot edits the text and reply markup of a message. +func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) { + params := telego.EditMessageTextParams{ + ChatID: tu.ID(chatId), + MessageID: messageID, + Text: text, + ParseMode: "HTML", + } + if len(inlineKeyboard) > 0 { + params.ReplyMarkup = inlineKeyboard[0] + } + if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil { + logger.Warning(err) + } +} + +// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay. +func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) { + // Determine if replyMarkup was passed; otherwise, set it to nil + var replyMarkupParam telego.ReplyMarkup + if len(replyMarkup) > 0 { + replyMarkupParam = replyMarkup[0] // Use the first element + } + + // Send the message + sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{ + ChatID: tu.ID(chatId), + Text: msg, + ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value + }) + if err != nil { + logger.Warning("Failed to send message:", err) + return + } + + // Delete the sent message after the specified number of seconds. + go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds) +} + +// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It +// deliberately does not touch the conversation state: every caller that ends a +// wizard step already clears the state synchronously, and clearing it here — up +// to several seconds later — would wipe a state the user set for the next step +// in the meantime, silently dropping their following input. +func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) { + time.Sleep(time.Duration(delayInSeconds) * time.Second) + t.deleteMessageTgBot(chatId, messageID) +} + +// deleteMessageTgBot deletes a message from the chat. +func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) { + if bot == nil { + return + } + params := telego.DeleteMessageParams{ + ChatID: tu.ID(chatId), + MessageID: messageID, + } + if err := bot.DeleteMessage(context.Background(), ¶ms); err != nil { + logger.Warning("Failed to delete message:", err) + } else { + logger.Info("Message deleted successfully") + } +} + +// TestConnection verifies the bot token is valid and the API is reachable. +func (t *Tgbot) TestConnection() error { + tgBotMutex.Lock() + b := bot + tgBotMutex.Unlock() + if b == nil { + return fmt.Errorf("bot not initialized") + } + me, err := b.GetMe(context.Background()) + if err != nil { + return fmt.Errorf("API unreachable: %w", err) + } + _ = me + return nil +} diff --git a/internal/web/service/tgbot/tgbot_send_test.go b/internal/web/service/tgbot/tgbot_send_test.go new file mode 100644 index 0000000..0541627 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_send_test.go @@ -0,0 +1,35 @@ +package tgbot + +import ( + "strings" + "testing" +) + +func TestPageMessageSplitsLinkListWithoutBlankLines(t *testing.T) { + var message strings.Builder + message.WriteString("Individual links:\r\n") + for range 50 { + message.WriteString("vless://" + strings.Repeat("a", 300) + "\r\n") + } + + pages := pageMessage(message.String(), telegramPageLimit) + if len(pages) < 2 { + t.Fatalf("pageMessage() returned %d page, want multiple", len(pages)) + } + + links := 0 + for index, page := range pages { + if len(page) > telegramPageLimit { + t.Errorf("page %d has %d bytes, want at most %d", index, len(page), telegramPageLimit) + } + openingTags := strings.Count(page, "") + closingTags := strings.Count(page, "") + if openingTags != closingTags { + t.Errorf("page %d has %d opening tags and %d closing tags", index, openingTags, closingTags) + } + links += openingTags + } + if links != 50 { + t.Errorf("pages contain %d links, want 50", links) + } +} diff --git a/internal/web/service/tgbot/tgbot_test.go b/internal/web/service/tgbot/tgbot_test.go new file mode 100644 index 0000000..77f8202 --- /dev/null +++ b/internal/web/service/tgbot/tgbot_test.go @@ -0,0 +1,125 @@ +package tgbot + +import ( + "io" + "net" + "reflect" + "testing" + "time" +) + +func TestLoginAttemptDoesNotCarryPassword(t *testing.T) { + typ := reflect.TypeFor[LoginAttempt]() + if _, ok := typ.FieldByName("Password"); ok { + t.Fatal("LoginAttempt must not carry attempted passwords") + } +} + +func TestIsSupportedBotProxyScheme(t *testing.T) { + supported := []string{ + "socks5://127.0.0.1:1080", + "http://127.0.0.1:8080", + "https://127.0.0.1:8080", + } + for _, p := range supported { + if !isSupportedBotProxyScheme(p) { + t.Errorf("expected %q to be supported", p) + } + } + unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"} + for _, p := range unsupported { + if isSupportedBotProxyScheme(p) { + t.Errorf("expected %q to be unsupported", p) + } + } +} + +func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + got = make(chan []byte, 1) + t.Cleanup(func() { _ = ln.Close() }) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, n) + m, _ := io.ReadFull(conn, buf) + got <- buf[:m] + }() + return ln.Addr().String(), got +} + +func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) { + addr, got := recordingDialTarget(t, len("CONNECT ")) + tg := &Tgbot{} + client := tg.createRobustFastHTTPClient("http://" + addr) + if client.Dial == nil { + t.Fatal("Dial must be set for an http:// proxy") + } + go func() { _, _ = client.Dial("example.com:443") }() + select { + case b := <-got: + if string(b) != "CONNECT " { + t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b) + } + case <-time.After(3 * time.Second): + t.Fatal("proxy never received a connection") + } +} + +func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) { + addr, got := recordingDialTarget(t, 1) + tg := &Tgbot{} + client := tg.createRobustFastHTTPClient("socks5://" + addr) + if client.Dial == nil { + t.Fatal("Dial must be set for a socks5:// proxy") + } + go func() { _, _ = client.Dial("example.com:443") }() + select { + case b := <-got: + if len(b) != 1 || b[0] != 0x05 { + t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b) + } + case <-time.After(3 * time.Second): + t.Fatal("proxy never received a connection") + } +} + +func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) { + tg := &Tgbot{} + client := tg.createRobustFastHTTPClient("") + if client.Dial != nil { + t.Fatal("Dial must be nil when no proxy is configured") + } +} + +func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) { + if !isCommandForBot("/status", "panel_bot") { + t.Fatal("untargeted commands must remain accepted") + } +} + +func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) { + if !isCommandForBot("/status@panel_bot", "Panel_Bot") { + t.Fatal("commands targeted to this bot must be accepted") + } +} + +func TestIsCommandForBotRejectsOtherUsername(t *testing.T) { + if isCommandForBot("/status@other_bot", "panel_bot") { + t.Fatal("commands targeted to another bot must be ignored") + } +} + +func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) { + if !isCommandForBot("/status@panel_bot", "") { + t.Fatal("commands must remain accepted when the current bot username is unavailable") + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ae1a94f..7bf041b 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -88,7 +88,8 @@ "noExpiry": "بدون انتهاء", "copyAllConfigs": "نسخ جميع الإعدادات", "copyAllConfigsCopied": "تم نسخ جميع الإعدادات", - "email": "البريد" + "email": "البريد", + "individualLinks": "روابط فردية" }, "menu": { "theme": "الثيم", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "الأسلوب المستخدم في طلبات probe.", "note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة." } - } + }, + "TGBotSettings": "بوت تيليجرام", + "notifications": "الإشعارات", + "notifyTime": { + "custom": "مخصص (crontab)", + "daily": "@daily — كل يوم الساعة 00:00", + "every": "@every — التكرار ضمن فترة", + "hourly": "@hourly — كل ساعة", + "hours": "ساعات", + "interval": "الفاصل الزمني", + "minutes": "دقائق", + "monthly": "@monthly — كل شهر", + "seconds": "ثوانٍ", + "unit": "الوحدة", + "weekly": "@weekly — كل أسبوع" + }, + "telegramAPIServer": "خادم API لتيليجرام", + "telegramAPIServerDesc": "سيرفر Telegram API المستخدم. سيبه فاضي لاستخدام الافتراضي.", + "telegramBotEnable": "تفعيل بوت Telegram", + "telegramBotEnableDesc": "يفعل بوت Telegram.", + "telegramBotLanguage": "لغة بوت Telegram", + "telegramChatId": "ID شات الأدمن", + "telegramChatIdDesc": "ID شات الأدمن في Telegram. (مفصول بفواصل)(تقدر تجيبه من {'@'}userinfobot) أو (استخدم '/id' في البوت)", + "telegramNotifyTime": "وقت الإشعار", + "telegramNotifyTimeDesc": "عدد مرات إرسال البوت للتقارير الدورية. اختر فترة جاهزة، أو اختر «مخصص» لإدخال تعبير crontab.", + "telegramToken": "رمز تيليجرام", + "telegramTokenConfigured": "مهيأ؛ اتركه فارغاً للاحتفاظ بالتوكن الحالي.", + "telegramTokenDesc": "توكن البوت اللي جبت من '{'@'}BotFather'.", + "telegramTokenPlaceholder": "مهيأ — أدخل توكن جديد لاستبداله", + "testTgBot": "إرسال رسالة تجريبية", + "tgBotNotEnabled": "بوت Telegram غير مفعّل", + "tgBotNotRunning": "بوت Telegram لا يعمل", + "tgEventBusNotify": "إشعارات الأحداث عبر Telegram", + "tgEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات Telegram", + "tgNotifyBackup": "نسخة احتياطية لقاعدة البيانات", + "tgNotifyBackupDesc": "ابعت ملف النسخة الاحتياطية لقاعدة البيانات مع التقرير.", + "tgTestFailed": "فشل اختبار Telegram", + "tgTestSuccess": "تم إرسال رسالة تجريبية إلى Telegram" }, "xray": { "save": "احفظ", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ لا يوجد سجل IP!" + "noIpRecord": "❗ لا يوجد سجل IP!", + "keyboardClosed": "❌ لوحة المفاتيح مغلقة!", + "noResult": "❗ لا يوجد نتائج!", + "noQuery": "❌ لم يتم العثور على الاستعلام! يرجى استخدام الأمر مرة أخرى!", + "wentWrong": "❌ حدث خطأ ما!", + "noInbounds": "❗ لم يتم العثور على أي وارد!", + "unlimited": "♾ غير محدود (إعادة تعيين)", + "add": "إضافة", + "month": "شهر", + "months": "أشهر", + "days": "أيام", + "hours": "ساعات", + "minutes": "دقائق", + "unknown": "غير معروف", + "inbounds": "الواردات", + "clients": "العملاء", + "offline": "🔴 غير متصل", + "online": "🟢 متصل", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ العملية نجحت!", + "restartFailed": "❗ حصل خطأ في العملية.\r\n\r\nError: {{ .Error }}.", + "xrayNotRunning": "❗ Xray Core مش شغال.", + "startDesc": "عرض القائمة الرئيسية", + "helpDesc": "مساعدة البوت", + "statusDesc": "التحقق من حالة البوت", + "idDesc": "عرض معرف Telegram الخاص بك", + "usageDesc": "عرض استهلاك العميل: /usage البريد", + "inboundDesc": "البحث في الواردات: /inbound الاسم (مشرف)", + "restartDesc": "إعادة تشغيل نواة Xray (مشرف)", + "clearallDesc": "تصفير استهلاك جميع العملاء (مشرف)" + }, + "messages": { + "cpuThreshold": "حمل المعالج {{ .Percent }}% عدى الحد المسموح ({{ .Threshold }}%)", + "selectUserFailed": "❌ حصل خطأ في اختيار المستخدم!", + "userSaved": "✅ حفظت بيانات مستخدم Telegram.", + "loginSuccess": "✅ تسجيل الدخول للبانل تم بنجاح.\r\n", + "loginFailed": "❗️فشل محاولة تسجيل الدخول للبانل.\r\n", + "report": "🕰 التقارير المجدولة: {{ .RunTime }}\r\n", + "datetime": "⏰ التاريخ والوقت: {{ .DateTime }}\r\n", + "hostname": "💻 المضيف: {{ .Hostname }}\r\n", + "version": "🚀 نسخة 3X-UI: {{ .Version }}\r\n", + "xrayVersion": "📡 نسخة Xray: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPs:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ وقت التشغيل: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 تحميل النظام: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 الترافيك: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ الحالة: {{ .State }}\r\n", + "username": "👤 اسم المستخدم: {{ .Username }}\r\n", + "reason": "❗️ السبب: {{ .Reason }}\r\n", + "time": "⏰ الوقت: {{ .Time }}\r\n", + "inbound": "📍 الوارد: {{ .Remark }}\r\n", + "port": "🔌 المنفذ: {{ .Port }}\r\n", + "expire": "📅 تاريخ الانتهاء: {{ .Time }}\r\n", + "expireIn": "📅 هيخلص بعد: {{ .Time }}\r\n", + "active": "💡 مفعل: {{ .Enable }}\r\n", + "enabled": "🚨 مفعل: {{ .Enable }}\r\n", + "online": "🌐 حالة الاتصال: {{ .Status }}\r\n", + "lastOnline": "🔙 آخر متصل: {{ .Time }}\r\n", + "email": "📧 البريد: {{ .Email }}\r\n", + "upload": "🔼 الرفع: ↑{{ .Upload }}\r\n", + "download": "🔽 التنزيل: ↓{{ .Download }}\r\n", + "total": "📊 الإجمالي: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 مستخدم Telegram: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 عدد النفاذ لـ {{ .Type }}:\r\n", + "onlinesCount": "🌐 العملاء الأونلاين: {{ .Count }}\r\n", + "disabled": "🛑 معطل: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 هينتهي قريب: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 وقت النسخة الاحتياطية: {{ .Time }}\r\n", + "refreshedOn": "\r\n📋🔄 اتحدّث في: {{ .Time }}\r\n\r\n", + "yes": "✅ أيوه", + "no": "❌ لا", + "received_email": "📧📥 الإيميل اتحدث.", + "received_comment": "💬📥 التعليق اتحدث.", + "email_prompt": "📧 الإيميل الافتراضي: {{ .ClientEmail }}\n\nادخل الإيميل بتاعك.", + "comment_prompt": "💬 التعليق الافتراضي: {{ .ClientComment }}\n\nادخل تعليقك.", + "cancel": "❌ العملية اتلغت! \n\nممكن تبدأ من /start في أي وقت. 🔄", + "error_add_client": "⚠️ خطأ:\n\n {{ .error }}", + "using_default_value": "تمام، هشيل على القيمة الافتراضية. 😊", + "incorrect_input": "المدخلات مش صحيحة.\nالكلمات لازم تكون متصلة من غير فراغات.\nمثال صحيح: aaaaaa\nمثال غلط: aaa aaa 🚫", + "AreYouSure": "إنت متأكد؟ 🤔", + "SuccessResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ✅ تم بنجاح", + "FailedResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ❌ فشل \n\n🛠️ الخطأ: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء.", + "eventOutboundDown": "الصادر {{ .Tag }} غير متصل", + "eventOutboundUp": "الصادر {{ .Tag }} متصل", + "eventErrorDetail": "الخطأ: {{ .Error }}", + "eventDelayDetail": "التأخير: {{ .Delay }} مللي ثانية", + "eventXrayCrash": "تعطّل Xray", + "eventXrayCrashError": "الخطأ: {{ .Error }}", + "eventNodeDown": "العقدة {{ .Name }} غير متصلة", + "eventNodeUp": "العقدة {{ .Name }} متصلة", + "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}", + "memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%" + }, + "buttons": { + "closeKeyboard": "❌ اقفل الكيبورد", + "cancel": "❌ إلغاء", + "cancelReset": "❌ إلغاء إعادة الضبط", + "cancelIpLimit": "❌ إلغاء حد الـ IP", + "confirmResetTraffic": "✅ تأكيد إعادة ضبط الترافيك؟", + "confirmClearIps": "✅ تأكيد مسح الـ IPs؟", + "confirmRemoveTGUser": "✅ تأكيد حذف مستخدم Telegram؟", + "confirmToggle": "✅ تأكيد تفعيل/تعطيل المستخدم؟", + "dbBackup": "احصل على نسخة DB", + "serverUsage": "استخدام السيرفر", + "getInbounds": "احصل على الإدخالات", + "depleteSoon": "هينتهي قريب", + "clientUsage": "استخدام العميل", + "onlines": "العملاء الأونلاين", + "commands": "الأوامر", + "refresh": "🔄 تجديد", + "clearIPs": "❌ مسح الـ IPs", + "removeTGUser": "❌ حذف مستخدم Telegram", + "selectTGUser": "👤 اختار مستخدم Telegram", + "selectOneTGUser": "👤 اختار مستخدم Telegram:", + "resetTraffic": "📈 إعادة ضبط الترافيك", + "resetExpire": "📅 تغيير تاريخ الانتهاء", + "ipLog": "🔢 سجل الـ IP", + "ipLimit": "🔢 حد الـ IP", + "setTGUser": "👤 ضبط مستخدم Telegram", + "toggle": "🔘 تفعيل / تعطيل", + "custom": "🔢 مخصص", + "confirmNumber": "✅ تأكيد: {{ .Num }}", + "confirmNumberAdd": "✅ تأكيد إضافة: {{ .Num }}", + "limitTraffic": "🚧 حد الترافيك", + "getBanLogs": "احصل على سجلات الحظر", + "allClients": "كل العملاء", + "addClient": "إضافة عميل", + "submitDisable": "إرسال كمعطّل ☑️", + "submitEnable": "إرسال كمفعّل ✅", + "use_default": "🏷️ استخدام الإعدادات الافتراضية", + "change_email": "⚙️📧 البريد", + "change_comment": "⚙️💬 تعليق", + "ResetAllTraffics": "إعادة ضبط جميع الترافيك", + "SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب" + }, + "answers": { + "successfulOperation": "✅ العملية نجحت!", + "errorOperation": "❗ حصل خطأ في العملية.", + "getInboundsFailed": "❌ فشل الحصول على الإدخالات.", + "getClientsFailed": "❌ فشل الحصول على العملاء.", + "canceled": "❌ {{ .Email }}: العملية اتلغت.", + "clientRefreshSuccess": "✅ {{ .Email }}: العميل اتحدث بنجاح.", + "IpRefreshSuccess": "✅ {{ .Email }}: الـ IPs اتحدثت بنجاح.", + "TGIdRefreshSuccess": "✅ {{ .Email }}: مستخدم Telegram اتحدث بنجاح.", + "resetTrafficSuccess": "✅ {{ .Email }}: الترافيك اتظبط بنجاح.", + "setTrafficLimitSuccess": "✅ {{ .Email }}: حد الترافيك اتسجل بنجاح.", + "expireResetSuccess": "✅ {{ .Email }}: أيام الانتهاء اتظبطت بنجاح.", + "resetIpSuccess": "✅ {{ .Email }}: حد الـ IP ({{ .Count }}) اتسجل بنجاح.", + "clearIpSuccess": "✅ {{ .Email }}: الـ IPs اتمسحت بنجاح.", + "getIpLog": "✅ {{ .Email }}: سجل الـ IP اتجاب.", + "getUserInfo": "✅ {{ .Email }}: بيانات مستخدم Telegram اتجاب.", + "removedTGUserSuccess": "✅ {{ .Email }}: مستخدم Telegram اتحذف بنجاح.", + "enableSuccess": "✅ {{ .Email }}: اتفعل بنجاح.", + "disableSuccess": "✅ {{ .Email }}: اتعطل بنجاح.", + "askToAddUserId": "مافيش إعدادات ليك!\r\nاطلب من الأدمن يضيف الـ Telegram ChatID الخاص بيك في إعداداتك.\r\n\r\nالـ ChatID بتاعك: {{ .TgUserID }}", + "chooseClient": "اختار عميل للإدخال {{ .Inbound }}", + "chooseInbound": "اختار الإدخال" + } }, "email": {} } diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 42e9df5..cb9a441 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -88,7 +88,8 @@ "unlimited": "Unlimited", "noExpiry": "No expiry", "copyAllConfigs": "Copy All Configs", - "copyAllConfigsCopied": "All configs copied" + "copyAllConfigsCopied": "All configs copied", + "individualLinks": "Individual links" }, "menu": { "theme": "Theme", @@ -1286,7 +1287,44 @@ "httpMethodDesc": "Method used for probe requests.", "note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart." } - } + }, + "TGBotSettings": "Telegram Bot", + "notifications": "Notifications", + "notifyTime": { + "custom": "Custom (crontab)", + "daily": "@daily — every day at 00:00", + "every": "@every — repeat at an interval", + "hourly": "@hourly — every hour", + "hours": "Hours", + "interval": "Interval", + "minutes": "Minutes", + "monthly": "@monthly — every month", + "seconds": "Seconds", + "unit": "Unit", + "weekly": "@weekly — every week" + }, + "telegramAPIServer": "Telegram API Server", + "telegramAPIServerDesc": "The Telegram API server to use. Leave blank to use the default server.", + "telegramBotEnable": "Enable Telegram Bot", + "telegramBotEnableDesc": "Enables the Telegram bot.", + "telegramBotLanguage": "Telegram Bot Language", + "telegramChatId": "Admin Chat ID", + "telegramChatIdDesc": "The Telegram Admin Chat ID(s). (comma-separated)(get it here {'@'}userinfobot) or (use '/id' command in the bot)", + "telegramNotifyTime": "Notification Time", + "telegramNotifyTimeDesc": "How often the Telegram bot sends periodic reports. Pick a preset interval, or choose Custom to enter a raw crontab expression.", + "telegramToken": "Telegram Token", + "telegramTokenConfigured": "Configured; leave blank to keep current token.", + "telegramTokenDesc": "The Telegram bot token obtained from '{'@'}BotFather'.", + "telegramTokenPlaceholder": "Configured - enter a new token to replace", + "testTgBot": "Send Test Message", + "tgBotNotEnabled": "Telegram bot is not enabled", + "tgBotNotRunning": "Telegram bot not running", + "tgEventBusNotify": "Telegram Event Notifications", + "tgEventBusNotifyDesc": "Select which events trigger Telegram notifications", + "tgNotifyBackup": "Database Backup", + "tgNotifyBackupDesc": "Send a database backup file with a report.", + "tgTestFailed": "Telegram test failed", + "tgTestSuccess": "Test message sent to Telegram" }, "xray": { "save": "Save", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ No IP Record!" + "noIpRecord": "❗ No IP Record!", + "keyboardClosed": "❌ Custom keyboard closed!", + "noResult": "❗ No result!", + "noQuery": "❌ Query not found! Please use the command again!", + "wentWrong": "❌ Something went wrong!", + "noInbounds": "❗ No inbound found!", + "unlimited": "♾ Unlimited(Reset)", + "add": "Add", + "month": "Month", + "months": "Months", + "days": "Days", + "hours": "Hours", + "minutes": "Minutes", + "unknown": "Unknown", + "inbounds": "Inbounds", + "clients": "Clients", + "offline": "🔴 Offline", + "online": "🟢 Online", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ Operation successful!", + "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)", + "clearallDesc": "Reset all clients' traffic (admin)" + }, + "messages": { + "cpuThreshold": "CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%", + "selectUserFailed": "❌ Error in user selection!", + "userSaved": "✅ Telegram User saved.", + "loginSuccess": "✅ Logged in to the panel successfully.\r\n", + "loginFailed": "❗️Login attempt to the panel failed.\r\n", + "report": "🕰 Scheduled Reports: {{ .RunTime }}\r\n", + "datetime": "⏰ Date&Time: {{ .DateTime }}\r\n", + "hostname": "💻 Host: {{ .Hostname }}\r\n", + "version": "🚀 3X-UI Version: {{ .Version }}\r\n", + "xrayVersion": "📡 Xray Version: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPs:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Uptime: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 System Load: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 Traffic: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Status: {{ .State }}\r\n", + "username": "👤 Username: {{ .Username }}\r\n", + "reason": "❗️ Reason: {{ .Reason }}\r\n", + "time": "⏰ Time: {{ .Time }}\r\n", + "inbound": "📍 Inbound: {{ .Remark }}\r\n", + "port": "🔌 Port: {{ .Port }}\r\n", + "expire": "📅 Expire Date: {{ .Time }}\r\n", + "expireIn": "📅 Expire In: {{ .Time }}\r\n", + "active": "💡 Active: {{ .Enable }}\r\n", + "enabled": "🚨 Enabled: {{ .Enable }}\r\n", + "online": "🌐 Connection status: {{ .Status }}\r\n", + "lastOnline": "🔙 Last online: {{ .Time }}\r\n", + "email": "📧 Email: {{ .Email }}\r\n", + "upload": "🔼 Upload: ↑{{ .Upload }}\r\n", + "download": "🔽 Download: ↓{{ .Download }}\r\n", + "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Telegram User: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 Exhausted {{ .Type }} count:\r\n", + "onlinesCount": "🌐 Online Clients: {{ .Count }}\r\n", + "disabled": "🛑 Disabled: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 Deplete Soon: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Backup Time: {{ .Time }}\r\n", + "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.", + "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 🚫", + "AreYouSure": "Are you sure? 🤔", + "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ✅ Success", + "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ❌ Failed \n\n🛠️ Error: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Traffic reset process finished for all clients.", + "eventOutboundDown": "Outbound {{ .Tag }} is DOWN", + "eventOutboundUp": "Outbound {{ .Tag }} is UP", + "eventErrorDetail": "Error: {{ .Error }}", + "eventDelayDetail": "Delay: {{ .Delay }}ms", + "eventXrayCrash": "Xray CRASHED", + "eventXrayCrashError": "Error: {{ .Error }}", + "eventNodeDown": "Node {{ .Name }} is DOWN", + "eventNodeUp": "Node {{ .Name }} is UP", + "eventLoginFallback": "Login failed from {{ .Source }}", + "memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%" + }, + "buttons": { + "closeKeyboard": "❌ Close Keyboard", + "cancel": "❌ Cancel", + "cancelReset": "❌ Cancel Reset", + "cancelIpLimit": "❌ Cancel IP Limit", + "confirmResetTraffic": "✅ Confirm Reset Traffic?", + "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", + "refresh": "🔄 Refresh", + "clearIPs": "❌ Clear IPs", + "removeTGUser": "❌ Remove Telegram User", + "selectTGUser": "👤 Select Telegram User", + "selectOneTGUser": "👤 Select a Telegram User:", + "resetTraffic": "📈 Reset Traffic", + "resetExpire": "📅 Change Expiry Date", + "ipLog": "🔢 IP Log", + "ipLimit": "🔢 IP Limit", + "setTGUser": "👤 Set Telegram User", + "toggle": "🔘 Enable / Disable", + "custom": "🔢 Custom", + "confirmNumber": "✅ Confirm: {{ .Num }}", + "confirmNumberAdd": "✅ Confirm adding: {{ .Num }}", + "limitTraffic": "🚧 Traffic Limit", + "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" + }, + "answers": { + "successfulOperation": "✅ Operation successful!", + "errorOperation": "❗ Error in operation.", + "getInboundsFailed": "❌ Failed to get inbounds.", + "getClientsFailed": "❌ Failed to get clients.", + "canceled": "❌ {{ .Email }}: Operation canceled.", + "clientRefreshSuccess": "✅ {{ .Email }}: Client refreshed successfully.", + "IpRefreshSuccess": "✅ {{ .Email }}: IPs refreshed successfully.", + "TGIdRefreshSuccess": "✅ {{ .Email }}: Client's Telegram User refreshed successfully.", + "resetTrafficSuccess": "✅ {{ .Email }}: Traffic reset successfully.", + "setTrafficLimitSuccess": "✅ {{ .Email }}: Traffic limit saved successfully.", + "expireResetSuccess": "✅ {{ .Email }}: Expire days reset successfully.", + "resetIpSuccess": "✅ {{ .Email }}: IP limit {{ .Count }} saved successfully.", + "clearIpSuccess": "✅ {{ .Email }}: IPs cleared successfully.", + "getIpLog": "✅ {{ .Email }}: Get IP Log.", + "getUserInfo": "✅ {{ .Email }}: Get Telegram User Info.", + "removedTGUserSuccess": "✅ {{ .Email }}: Telegram User removed successfully.", + "enableSuccess": "✅ {{ .Email }}: Enabled successfully.", + "disableSuccess": "✅ {{ .Email }}: Disabled successfully.", + "askToAddUserId": "Your configuration is not found!\r\nPlease ask your admin to use your Telegram ChatID in your configuration(s).\r\n\r\nYour ChatID: {{ .TgUserID }}", + "chooseClient": "Choose a Client for Inbound {{ .Inbound }}", + "chooseInbound": "Choose an Inbound" + } }, "email": {} } diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index c88baa8..a5f4042 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -88,7 +88,8 @@ "noExpiry": "Sin caducidad", "copyAllConfigs": "Copiar Todas las Configuraciones", "copyAllConfigsCopied": "Todas las configuraciones copiadas", - "email": "Email" + "email": "Email", + "individualLinks": "Enlaces individuales" }, "menu": { "theme": "Tema", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "Método usado para las solicitudes de probe.", "note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel." } - } + }, + "TGBotSettings": "Bot de Telegram", + "notifications": "Notificaciones", + "notifyTime": { + "custom": "Personalizado (crontab)", + "daily": "@daily — cada día a las 00:00", + "every": "@every — repetir en un intervalo", + "hourly": "@hourly — cada hora", + "hours": "Horas", + "interval": "Intervalo", + "minutes": "Minutos", + "monthly": "@monthly — cada mes", + "seconds": "Segundos", + "unit": "Unidad", + "weekly": "@weekly — cada semana" + }, + "telegramAPIServer": "Servidor API de Telegram", + "telegramAPIServerDesc": "El servidor API de Telegram a utilizar. Déjelo en blanco para utilizar el servidor predeterminado.", + "telegramBotEnable": "Habilitar bot de Telegram", + "telegramBotEnableDesc": "Conéctese a las funciones de este panel a través del bot de Telegram.", + "telegramBotLanguage": "Idioma del Bot de Telegram", + "telegramChatId": "IDs de Chat de Telegram para Administradores", + "telegramChatIdDesc": "IDs de Chat múltiples separados por comas. Use {'@'}userinfobot o use el comando '/id' en el bot para obtener sus IDs de Chat.", + "telegramNotifyTime": "Hora de Notificación del Bot de Telegram", + "telegramNotifyTimeDesc": "Con qué frecuencia el bot de Telegram envía informes periódicos. Elige un intervalo predefinido o selecciona Personalizado para introducir una expresión crontab.", + "telegramToken": "Token de Telegram", + "telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.", + "telegramTokenDesc": "Debe obtener el token del administrador de bots de Telegram {'@'}botfather.", + "telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo", + "testTgBot": "Enviar mensaje de prueba", + "tgBotNotEnabled": "El bot de Telegram no está activado", + "tgBotNotRunning": "El bot de Telegram no está en ejecución", + "tgEventBusNotify": "Notificaciones de Telegram de eventos", + "tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram", + "tgNotifyBackup": "Respaldo de Base de Datos", + "tgNotifyBackupDesc": "Incluir archivo de respaldo de base de datos con notificación de informe.", + "tgTestFailed": "La prueba de Telegram falló", + "tgTestSuccess": "Mensaje de prueba enviado a Telegram" }, "xray": { "save": "Guardar configuración", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ ¡No hay registro de IP!" + "noIpRecord": "❗ ¡No hay registro de IP!", + "keyboardClosed": "❌ Teclado cerrado!", + "noResult": "❗ ¡Sin resultados!", + "noQuery": "❌ ¡Consulta no encontrada! ¡Por favor, use el comando nuevamente!", + "wentWrong": "❌ ¡Algo salió mal!", + "noInbounds": "❗ ¡No se encontraron entradas!", + "unlimited": "♾ Ilimitado (Restablecer)", + "add": "Añadir", + "month": "Mes", + "months": "Meses", + "days": "Días", + "hours": "Horas", + "minutes": "Minutos", + "unknown": "Desconocido", + "inbounds": "Entradas", + "clients": "Clientes", + "offline": "🔴 Sin conexión", + "online": "🟢 En línea", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ ¡Operación exitosa!", + "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)", + "clearallDesc": "Restablecer el tráfico de todos los clientes (admin)" + }, + "messages": { + "cpuThreshold": "El uso de CPU {{ .Percent }}% es mayor que el umbral {{ .Threshold }}%", + "selectUserFailed": "❌ ¡Error al seleccionar usuario!", + "userSaved": "✅ Usuario de Telegram guardado.", + "loginSuccess": "✅ Has iniciado sesión en el panel con éxito.\r\n", + "loginFailed": "❗️ Falló el inicio de sesión en el panel.\r\n", + "report": "🕰 Informes programados: {{ .RunTime }}\r\n", + "datetime": "⏰ Fecha y Hora: {{ .DateTime }}\r\n", + "hostname": "💻 Host: {{ .Hostname }}\r\n", + "version": "🚀 Versión de X-UI: {{ .Version }}\r\n", + "xrayVersion": "📡 Versión de Xray: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPs:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Tiempo de actividad del servidor: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 Carga del servidor: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 Tráfico: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Estado: {{ .State }}\r\n", + "username": "👤 Nombre de usuario: {{ .Username }}\r\n", + "reason": "❗️ Motivo: {{ .Reason }}\r\n", + "time": "⏰ Hora: {{ .Time }}\r\n", + "inbound": "📍 Entrada: {{ .Remark }}\r\n", + "port": "🔌 Puerto: {{ .Port }}\r\n", + "expire": "📅 Fecha de Vencimiento: {{ .Time }}\r\n", + "expireIn": "📅 Vence en: {{ .Time }}\r\n", + "active": "💡 Activo: {{ .Enable }}\r\n", + "enabled": "🚨 Habilitado: {{ .Enable }}\r\n", + "online": "🌐 Estado de conexión: {{ .Status }}\r\n", + "lastOnline": "🔙 Última conexión: {{ .Time }}\r\n", + "email": "📧 Email: {{ .Email }}\r\n", + "upload": "🔼 Subida: ↑{{ .Upload }}\r\n", + "download": "🔽 Descarga: ↓{{ .Download }}\r\n", + "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Usuario de Telegram: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 Cantidad de Agotados {{ .Type }}:\r\n", + "onlinesCount": "🌐 Clientes en línea: {{ .Count }}\r\n", + "disabled": "🛑 Desactivado: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 Se agotará pronto: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Hora de la Copia de Seguridad: {{ .Time }}\r\n", + "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.", + "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 🚫", + "AreYouSure": "¿Estás seguro? 🤔", + "SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito", + "FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes.", + "eventOutboundDown": "El saliente {{ .Tag }} está CAÍDO", + "eventOutboundUp": "El saliente {{ .Tag }} está ACTIVO", + "eventErrorDetail": "Error: {{ .Error }}", + "eventDelayDetail": "Retardo: {{ .Delay }} ms", + "eventXrayCrash": "Xray se ha BLOQUEADO", + "eventXrayCrashError": "Error: {{ .Error }}", + "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 }}%" + }, + "buttons": { + "closeKeyboard": "❌ Cerrar Teclado", + "cancel": "❌ Cancelar", + "cancelReset": "❌ Cancelar Reinicio", + "cancelIpLimit": "❌ Cancelar Límite de IP", + "confirmResetTraffic": "✅ ¿Confirmar Reinicio de Tráfico?", + "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", + "refresh": "🔄 Actualizar", + "clearIPs": "❌ Limpiar IPs", + "removeTGUser": "❌ Eliminar Usuario de Telegram", + "selectTGUser": "👤 Seleccionar Usuario de Telegram", + "selectOneTGUser": "👤 Selecciona un usuario de telegram:", + "resetTraffic": "📈 Reiniciar Tráfico", + "resetExpire": "📅 Cambiar fecha de Vencimiento", + "ipLog": "🔢 Registro de IP", + "ipLimit": "🔢 Límite de IP", + "setTGUser": "👤 Establecer Usuario de Telegram", + "toggle": "🔘 Habilitar / Deshabilitar", + "custom": "🔢 Personalizado", + "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", + "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" + }, + "answers": { + "successfulOperation": "✅ ¡Exitosa!", + "errorOperation": "❗ Error en la Operación.", + "getInboundsFailed": "❌ Error al obtener las entradas", + "getClientsFailed": "❌ No se pudo obtener los clientes.", + "canceled": "❌ {{ .Email }} : Operación cancelada.", + "clientRefreshSuccess": "✅ {{ .Email }} : Cliente actualizado exitosamente.", + "IpRefreshSuccess": "✅ {{ .Email }} : IPs actualizadas exitosamente.", + "TGIdRefreshSuccess": "✅ {{ .Email }} : Usuario de Telegram del cliente actualizado exitosamente.", + "resetTrafficSuccess": "✅ {{ .Email }} : Tráfico reiniciado exitosamente.", + "setTrafficLimitSuccess": "✅ {{ .Email }} : Límite de Tráfico guardado exitosamente.", + "expireResetSuccess": "✅ {{ .Email }} : Días de vencimiento reiniciados exitosamente.", + "resetIpSuccess": "✅ {{ .Email }} : Límite de IP {{ .Count }} guardado exitosamente.", + "clearIpSuccess": "✅ {{ .Email }} : IPs limpiadas exitosamente.", + "getIpLog": "✅ {{ .Email }} : Obtener Registro de IP.", + "getUserInfo": "✅ {{ .Email }} : Obtener Información de Usuario de Telegram.", + "removedTGUserSuccess": "✅ {{ .Email }} : Usuario de Telegram eliminado exitosamente.", + "enableSuccess": "✅ {{ .Email }} : Habilitado exitosamente.", + "disableSuccess": "✅ {{ .Email }} : Deshabilitado exitosamente.", + "askToAddUserId": "¡No se encuentra su configuración!\r\nPor favor, pídale a su administrador que use su ChatID de usuario de Telegram en su(s) configuración(es).\r\n\r\nSu ChatID de usuario: {{ .TgUserID }}", + "chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}", + "chooseInbound": "Elige un Inbound" + } }, "email": {} } diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 175c86d..a8ee9ac 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -88,7 +88,8 @@ "unlimited": "نامحدود", "noExpiry": "بدون انقضا", "copyAllConfigs": "کپی همه کانفیگ‌ها", - "copyAllConfigsCopied": "همه کانفیگ‌ها کپی شدند" + "copyAllConfigsCopied": "همه کانفیگ‌ها کپی شدند", + "individualLinks": "لینک‌های تکی" }, "menu": { "theme": "تم", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "متد استفاده‌شده برای درخواست‌های probe.", "note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند." } - } + }, + "TGBotSettings": "ربات تلگرام", + "notifications": "اعلان‌ها", + "notifyTime": { + "custom": "سفارشی (crontab)", + "daily": "@daily — هر روز ساعت ۰۰:۰۰", + "every": "@every — تکرار در یک بازه", + "hourly": "@hourly — هر ساعت", + "hours": "ساعت", + "interval": "بازه زمانی", + "minutes": "دقیقه", + "monthly": "@monthly — هر ماه", + "seconds": "ثانیه", + "unit": "واحد", + "weekly": "@weekly — هر هفته" + }, + "telegramAPIServer": "سرور API تلگرام", + "telegramAPIServerDesc": "API سرور تلگرام برای اتصال را تغییر میدهد. برای استفاده از سرور پیش فرض خالی بگذارید", + "telegramBotEnable": "فعال‌سازی ربات تلگرام", + "telegramBotEnableDesc": "ربات تلگرام را فعال می‌کند", + "telegramBotLanguage": "زبان ربات تلگرام", + "telegramChatId": "آی‌دی چت مدیر", + "telegramChatIdDesc": "دریافت ‌کنید ('/id'یا (دستور ({'@'}userinfobot) آی‌دی(های) چت تلگرام مدیر، از", + "telegramNotifyTime": "زمان نوتیفیکیشن", + "telegramNotifyTimeDesc": "هر چند وقت یک‌بار ربات تلگرام گزارش دوره‌ای بفرستد. یک بازهٔ آماده انتخاب کنید یا گزینهٔ سفارشی را بزنید تا عبارت crontab وارد کنید.", + "telegramToken": "توکن تلگرام", + "telegramTokenConfigured": "پیکربندی شده؛ برای حفظ توکن فعلی خالی بگذارید.", + "telegramTokenDesc": "دریافت کنید {'@'}botfather توکن را می‌توانید از", + "telegramTokenPlaceholder": "پیکربندی شده - برای جایگزینی، توکن جدید وارد کنید", + "testTgBot": "ارسال پیام آزمایشی", + "tgBotNotEnabled": "ربات تلگرام فعال نیست", + "tgBotNotRunning": "ربات تلگرام در حال اجرا نیست", + "tgEventBusNotify": "اعلان‌های رویداد تلگرام", + "tgEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان تلگرام را فعال می‌کنند", + "tgNotifyBackup": "پشتیبان‌گیری از دیتابیس", + "tgNotifyBackupDesc": "فایل پشتیبان‌دیتابیس را به‌همراه گزارش ارسال می‌کند", + "tgTestFailed": "آزمایش تلگرام ناموفق بود", + "tgTestSuccess": "پیام آزمایشی به تلگرام ارسال شد" }, "xray": { "save": "ذخیره", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ رکورد آی پی وجود ندارد!" + "noIpRecord": "❗ رکورد آی پی وجود ندارد!", + "keyboardClosed": "❌ صفحه کلید بسته شد!", + "noResult": "❗ نتیجه ای یافت نشد!", + "noQuery": "❌ درخواست یافت نشد! لطفا دوباره تلاش کنید!", + "wentWrong": "❌ مشکلی پیش آمد!", + "noInbounds": "❗ هیچ ورودی یافت نشد!", + "unlimited": "♾ نامحدود(ریست)", + "add": "افزودن", + "month": "ماه", + "months": "ماه", + "days": "روز", + "hours": "ساعت", + "minutes": "دقیقه", + "unknown": "نامشخص", + "inbounds": "ورودی‌ها", + "clients": "کاربران", + "offline": "🔴 آفلاین", + "online": "🟢 آنلاین", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ عملیات با موفقیت انجام شد!", + "restartFailed": "❗ خطا در عملیات.\r\n\r\nخطا: {{ .Error }}.", + "xrayNotRunning": "❗ Xray Core در حال اجرا نیست.", + "startDesc": "نمایش منوی اصلی", + "helpDesc": "راهنمای ربات", + "statusDesc": "بررسی وضعیت ربات", + "idDesc": "نمایش شناسه تلگرام شما", + "usageDesc": "مشاهده مصرف کاربر: /usage ایمیل", + "inboundDesc": "جستجوی ورودی‌ها: /inbound نام (مدیر)", + "restartDesc": "راه‌اندازی مجدد هسته Xray (مدیر)", + "clearallDesc": "صفر کردن ترافیک همه کاربران (مدیر)" + }, + "messages": { + "cpuThreshold": "بار ‌پردازنده {{ .Percent }}% بیشتر از آستانه است {{ .Threshold }}%", + "selectUserFailed": "❌ خطا در انتخاب کاربر!", + "userSaved": "✅ کاربر تلگرام ذخیره شد.", + "loginSuccess": "✅ با موفقیت به پنل وارد شدید.\r\n", + "loginFailed": "❗️ ورود به پنل ناموفق‌بود \r\n", + "report": "🕰 گزارشات‌زمان‌بندی‌شده: {{ .RunTime }}\r\n", + "datetime": "⏰ تاریخ‌وزمان: {{ .DateTime }}\r\n", + "hostname": "💻 میزبان: {{ .Hostname }}\r\n", + "version": "🚀 نسخه‌پنل: {{ .Version }}\r\n", + "xrayVersion": "📡 نسخه‌هسته: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPها:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ مدت‌کارکردسیستم: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 بارسیستم: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 ترافیک: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ وضعیت: {{ .State }}\r\n", + "username": "👤 نام‌کاربری: {{ .Username }}\r\n", + "reason": "❗️ دلیل: {{ .Reason }}\r\n", + "time": "⏰ زمان: {{ .Time }}\r\n", + "inbound": "📍 ورودی: {{ .Remark }}\r\n", + "port": "🔌 پورت: {{ .Port }}\r\n", + "expire": "📅 تاریخ‌انقضا: {{ .Time }}\r\n\r\n", + "expireIn": "📅 باقی‌ مانده‌ تا انقضا: {{ .Time }}\r\n\r\n", + "active": "💡 فعال: {{ .Enable }}\r\n", + "enabled": "🚨 وضعیت: {{ .Enable }}\r\n", + "online": "🌐 وضعیت اتصال: {{ .Status }}\r\n", + "lastOnline": "🔙 آخرین فعالیت: {{ .Time }}\r\n", + "email": "📧 ایمیل: {{ .Email }}\r\n", + "upload": "🔼 آپلود: ↑{{ .Upload }}\r\n", + "download": "🔽 دانلود: ↓{{ .Download }}\r\n", + "total": "📊 کل: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 کاربر تلگرام: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 تعداد {{ .Type }} به‌اتمام‌رسیده‌است:\r\n", + "onlinesCount": "🌐 کاربران‌آنلاین: {{ .Count }}\r\n", + "disabled": "🛑 غیرفعال: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 به‌زودی‌به‌پایان‌خواهدرسید: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 زمان‌پشتیبان‌گیری: {{ .Time }}\r\n", + "refreshedOn": "\r\n📋🔄 تازه‌سازی شده در: {{ .Time }}\r\n\r\n", + "yes": "✅ بله", + "no": "❌ خیر", + "received_email": "📧📥 ایمیل به‌روزرسانی شد.", + "received_comment": "💬📥 نظر به‌روزرسانی شد.", + "email_prompt": "📧 ایمیل پیش‌فرض: {{ .ClientEmail }}\n\nایمیل خود را وارد کنید.", + "comment_prompt": "💬 نظر پیش‌فرض: {{ .ClientComment }}\n\nنظر خود را وارد کنید.", + "cancel": "❌ فرآیند لغو شد! \n\nمی‌توانید هر زمان که خواستید /start را دوباره اجرا کنید. 🔄", + "error_add_client": "⚠️ خطا:\n\n {{ .error }}", + "using_default_value": "باشه، از مقدار پیش‌فرض استفاده می‌کنم. 😊", + "incorrect_input": "ورودی شما معتبر نیست.\nعبارت‌ها باید بدون فاصله باشند.\nمثال صحیح: aaaaaa\nمثال نادرست: aaa aaa 🚫", + "AreYouSure": "مطمئنی؟ 🤔", + "SuccessResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیت‌آمیز", + "FailedResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید.", + "eventOutboundDown": "خروجی {{ .Tag }} قطع است", + "eventOutboundUp": "خروجی {{ .Tag }} وصل است", + "eventErrorDetail": "خطا: {{ .Error }}", + "eventDelayDetail": "تأخیر: {{ .Delay }} میلی‌ثانیه", + "eventXrayCrash": "Xray کرش کرد", + "eventXrayCrashError": "خطا: {{ .Error }}", + "eventNodeDown": "نود {{ .Name }} قطع است", + "eventNodeUp": "نود {{ .Name }} وصل است", + "eventLoginFallback": "ورود ناموفق از {{ .Source }}", + "memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است" + }, + "buttons": { + "closeKeyboard": "❌ بستن کیبورد", + "cancel": "❌ لغو", + "cancelReset": "❌ لغو تنظیم مجدد", + "cancelIpLimit": "❌ لغو محدودیت آی‌پی", + "confirmResetTraffic": "✅ تأیید تنظیم مجدد ترافیک؟", + "confirmClearIps": "✅ تأیید پاک‌سازی آدرس‌های آی‌پی؟", + "confirmRemoveTGUser": "✅ تأیید حذف کاربر تلگرام؟", + "confirmToggle": "✅ تایید فعال/غیرفعال کردن کاربر؟", + "dbBackup": "دریافت پشتیبان", + "serverUsage": "استفاده از سیستم", + "getInbounds": "دریافت ورودی‌ها", + "depleteSoon": "به‌زودی به پایان خواهد رسید", + "clientUsage": "دریافت آمار کاربر", + "onlines": "کاربران آنلاین", + "commands": "دستورات", + "refresh": "🔄 تازه‌سازی", + "clearIPs": "❌ پاک‌سازی آدرس‌ها", + "removeTGUser": "❌ حذف کاربر تلگرام", + "selectTGUser": "👤 انتخاب کاربر تلگرام", + "selectOneTGUser": "👤 یک کاربر تلگرام را انتخاب کنید:", + "resetTraffic": "📈 تنظیم مجدد ترافیک", + "resetExpire": "📅 تنظیم مجدد تاریخ انقضا", + "ipLog": "🔢 لاگ آدرس‌های IP", + "ipLimit": "🔢 محدودیت IP", + "setTGUser": "👤 تنظیم کاربر تلگرام", + "toggle": "🔘 فعال / غیرفعال", + "custom": "🔢 سفارشی", + "confirmNumber": "✅ تایید: {{ .Num }}", + "confirmNumberAdd": "✅ تایید اضافه کردن: {{ .Num }}", + "limitTraffic": "🚧 محدودیت ترافیک", + "getBanLogs": "گزارش های بلوک را دریافت کنید", + "allClients": "همه مشتریان", + "addClient": "افزودن مشتری", + "submitDisable": "ارسال به عنوان غیرفعال ☑️", + "submitEnable": "ارسال به عنوان فعال ✅", + "use_default": "🏷️ استفاده از پیش‌فرض", + "change_email": "⚙️📧 ایمیل", + "change_comment": "⚙️💬 نظر", + "ResetAllTraffics": "بازنشانی همه ترافیک‌ها", + "SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده" + }, + "answers": { + "successfulOperation": "✅ انجام شد!", + "errorOperation": "❗ خطا در عملیات.", + "getInboundsFailed": "❌ دریافت ورودی‌ها با خطا مواجه شد.", + "getClientsFailed": "❌ دریافت مشتریان با شکست مواجه شد.", + "canceled": "❌ {{ .Email }} : عملیات لغو شد.", + "clientRefreshSuccess": "✅ {{ .Email }} : کلاینت با موفقیت تازه‌سازی شد.", + "IpRefreshSuccess": "✅ {{ .Email }} : آدرس‌ها با موفقیت تازه‌سازی شدند.", + "TGIdRefreshSuccess": "✅ {{ .Email }} : کاربر تلگرام کلاینت با موفقیت تازه‌سازی شد.", + "resetTrafficSuccess": "✅ {{ .Email }} : ترافیک با موفقیت تنظیم مجدد شد.", + "setTrafficLimitSuccess": "✅ {{ .Email }} : محدودیت ترافیک با موفقیت ذخیره شد.", + "expireResetSuccess": "✅ {{ .Email }} : تاریخ انقضا با موفقیت تنظیم مجدد شد.", + "resetIpSuccess": "✅ {{ .Email }} : محدودیت آدرس IP {{ .Count }} با موفقیت ذخیره شد.", + "clearIpSuccess": "✅ {{ .Email }} : آدرس‌ها با موفقیت پاک‌سازی شدند.", + "getIpLog": "✅ {{ .Email }} : دریافت لاگ آدرس‌های IP.", + "getUserInfo": "✅ {{ .Email }} : دریافت اطلاعات کاربر تلگرام.", + "removedTGUserSuccess": "✅ {{ .Email }} : کاربر تلگرام با موفقیت حذف شد.", + "enableSuccess": "✅ {{ .Email }} : با موفقیت فعال شد.", + "disableSuccess": "✅ {{ .Email }} : با موفقیت غیرفعال شد.", + "askToAddUserId": "پیکربندی شما یافت نشد!\r\nلطفاً از مدیر خود بخواهید که شناسه کاربر تلگرام خود را در پیکربندی (های) خود استفاده کند.\r\n\r\nشناسه کاربری شما: {{ .TgUserID }}", + "chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید", + "chooseInbound": "یک ورودی انتخاب کنید" + } }, "email": {} } diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index bc1443f..38e43ee 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -88,7 +88,8 @@ "noExpiry": "Tanpa kedaluwarsa", "copyAllConfigs": "Salin Semua Konfigurasi", "copyAllConfigsCopied": "Semua konfigurasi tersalin", - "email": "Email" + "email": "Email", + "individualLinks": "Tautan individual" }, "menu": { "theme": "Tema", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "Metode yang dipakai untuk permintaan probe.", "note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang." } - } + }, + "TGBotSettings": "Bot Telegram", + "notifications": "Notifikasi", + "notifyTime": { + "custom": "Kustom (crontab)", + "daily": "@daily — setiap hari pukul 00:00", + "every": "@every — ulangi dalam interval", + "hourly": "@hourly — setiap jam", + "hours": "Jam", + "interval": "Interval", + "minutes": "Menit", + "monthly": "@monthly — setiap bulan", + "seconds": "Detik", + "unit": "Satuan", + "weekly": "@weekly — setiap minggu" + }, + "telegramAPIServer": "Server API Telegram", + "telegramAPIServerDesc": "Server API Telegram yang akan digunakan. Biarkan kosong untuk menggunakan server default.", + "telegramBotEnable": "Aktifkan Bot Telegram", + "telegramBotEnableDesc": "Mengaktifkan bot Telegram.", + "telegramBotLanguage": "Bahasa Bot Telegram", + "telegramChatId": "ID Obrolan Admin", + "telegramChatIdDesc": "ID Obrolan Admin Telegram. (dipisahkan koma)(dapatkan di sini {'@'}userinfobot) atau (gunakan perintah '/id' di bot)", + "telegramNotifyTime": "Waktu Notifikasi", + "telegramNotifyTimeDesc": "Seberapa sering bot Telegram mengirim laporan berkala. Pilih interval siap pakai, atau pilih Kustom untuk memasukkan ekspresi crontab.", + "telegramToken": "Token Telegram", + "telegramTokenConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan token saat ini.", + "telegramTokenDesc": "Token bot Telegram yang diperoleh dari '{'@'}BotFather'.", + "telegramTokenPlaceholder": "Terkonfigurasi - masukkan token baru untuk mengganti", + "testTgBot": "Kirim Pesan Uji", + "tgBotNotEnabled": "Bot Telegram tidak aktif", + "tgBotNotRunning": "Bot Telegram tidak berjalan", + "tgEventBusNotify": "Notifikasi Peristiwa Telegram", + "tgEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi Telegram", + "tgNotifyBackup": "Cadangan Database", + "tgNotifyBackupDesc": "Kirim berkas cadangan database dengan laporan.", + "tgTestFailed": "Uji Telegram gagal", + "tgTestSuccess": "Pesan uji terkirim ke Telegram" }, "xray": { "save": "Simpan", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ Tidak ada Catatan IP!" + "noIpRecord": "❗ Tidak ada Catatan IP!", + "keyboardClosed": "❌ Keyboard ditutup!", + "noResult": "❗ Tidak ada hasil!", + "noQuery": "❌ Kueri tidak ditemukan! Silakan gunakan perintah lagi!", + "wentWrong": "❌ Terjadi kesalahan!", + "noInbounds": "❗ Tidak ada inbound yang ditemukan!", + "unlimited": "♾ Tidak terbatas (Reset)", + "add": "Tambah", + "month": "Bulan", + "months": "Bulan", + "days": "Hari", + "hours": "Jam", + "minutes": "Menit", + "unknown": "Tidak diketahui", + "inbounds": "Inbound", + "clients": "Klien", + "offline": "🔴 Offline", + "online": "🟢 Online", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ Operasi berhasil!", + "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)", + "clearallDesc": "Reset trafik semua klien (admin)" + }, + "messages": { + "cpuThreshold": "Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%", + "selectUserFailed": "❌ Kesalahan dalam pemilihan pengguna!", + "userSaved": "✅ Pengguna Telegram tersimpan.", + "loginSuccess": "✅ Berhasil masuk ke panel.\r\n", + "loginFailed": "❗️ Gagal masuk ke panel.\r\n", + "report": "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n", + "datetime": "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n", + "hostname": "💻 Host: {{ .Hostname }}\r\n", + "version": "🚀 Versi 3X-UI: {{ .Version }}\r\n", + "xrayVersion": "📡 Versi Xray: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IP:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Waktu Aktif: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 Beban Sistem: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 Lalu Lintas: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Status: {{ .State }}\r\n", + "username": "👤 Nama Pengguna: {{ .Username }}\r\n", + "reason": "❗️ Alasan: {{ .Reason }}\r\n", + "time": "⏰ Waktu: {{ .Time }}\r\n", + "inbound": "📍 Inbound: {{ .Remark }}\r\n", + "port": "🔌 Port: {{ .Port }}\r\n", + "expire": "📅 Tanggal Kadaluarsa: {{ .Time }}\r\n", + "expireIn": "📅 Kadaluarsa Dalam: {{ .Time }}\r\n", + "active": "💡 Aktif: {{ .Enable }}\r\n", + "enabled": "🚨 Diaktifkan: {{ .Enable }}\r\n", + "online": "🌐 Status Koneksi: {{ .Status }}\r\n", + "lastOnline": "🔙 Terakhir online: {{ .Time }}\r\n", + "email": "📧 Email: {{ .Email }}\r\n", + "upload": "🔼 Unggah: ↑{{ .Upload }}\r\n", + "download": "🔽 Unduh: ↓{{ .Download }}\r\n", + "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Pengguna Telegram: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 Jumlah Habis {{ .Type }}:\r\n", + "onlinesCount": "🌐 Klien Online: {{ .Count }}\r\n", + "disabled": "🛑 Dinonaktifkan: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 Habis Sebentar: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Waktu Backup: {{ .Time }}\r\n", + "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.", + "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 🚫", + "AreYouSure": "Apakah kamu yakin? 🤔", + "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil", + "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien.", + "eventOutboundDown": "Outbound {{ .Tag }} MATI", + "eventOutboundUp": "Outbound {{ .Tag }} AKTIF", + "eventErrorDetail": "Kesalahan: {{ .Error }}", + "eventDelayDetail": "Penundaan: {{ .Delay }}ms", + "eventXrayCrash": "Xray CRASH", + "eventXrayCrashError": "Kesalahan: {{ .Error }}", + "eventNodeDown": "Node {{ .Name }} MATI", + "eventNodeUp": "Node {{ .Name }} AKTIF", + "eventLoginFallback": "Gagal masuk dari {{ .Source }}", + "memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%" + }, + "buttons": { + "closeKeyboard": "❌ Tutup Papan Ketik", + "cancel": "❌ Batal", + "cancelReset": "❌ Batal Reset", + "cancelIpLimit": "❌ Batal Batas IP", + "confirmResetTraffic": "✅ Konfirmasi Reset Lalu Lintas?", + "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", + "refresh": "🔄 Perbarui", + "clearIPs": "❌ Hapus IPs", + "removeTGUser": "❌ Hapus Pengguna Telegram", + "selectTGUser": "👤 Pilih Pengguna Telegram", + "selectOneTGUser": "👤 Pilih Pengguna Telegram:", + "resetTraffic": "📈 Reset Lalu Lintas", + "resetExpire": "📅 Ubah Tanggal Kadaluarsa", + "ipLog": "🔢 Log IP", + "ipLimit": "🔢 Batas IP", + "setTGUser": "👤 Set Pengguna Telegram", + "toggle": "🔘 Aktifkan / Nonaktifkan", + "custom": "🔢 Kustom", + "confirmNumber": "✅ Konfirmasi: {{ .Num }}", + "confirmNumberAdd": "✅ Konfirmasi menambahkan: {{ .Num }}", + "limitTraffic": "🚧 Batas Lalu Lintas", + "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" + }, + "answers": { + "successfulOperation": "✅ Operasi berhasil!", + "errorOperation": "❗ Kesalahan dalam operasi.", + "getInboundsFailed": "❌ Gagal mendapatkan inbounds.", + "getClientsFailed": "❌ Gagal mendapatkan klien.", + "canceled": "❌ {{ .Email }}: Operasi dibatalkan.", + "clientRefreshSuccess": "✅ {{ .Email }}: Klien diperbarui dengan berhasil.", + "IpRefreshSuccess": "✅ {{ .Email }}: IP diperbarui dengan berhasil.", + "TGIdRefreshSuccess": "✅ {{ .Email }}: Pengguna Telegram Klien diperbarui dengan berhasil.", + "resetTrafficSuccess": "✅ {{ .Email }}: Lalu lintas direset dengan berhasil.", + "setTrafficLimitSuccess": "✅ {{ .Email }}: Batas lalu lintas disimpan dengan berhasil.", + "expireResetSuccess": "✅ {{ .Email }}: Hari kadaluarsa direset dengan berhasil.", + "resetIpSuccess": "✅ {{ .Email }}: Batas IP {{ .Count }} disimpan dengan berhasil.", + "clearIpSuccess": "✅ {{ .Email }}: IP dihapus dengan berhasil.", + "getIpLog": "✅ {{ .Email }}: Dapatkan Log IP.", + "getUserInfo": "✅ {{ .Email }}: Dapatkan Info Pengguna Telegram.", + "removedTGUserSuccess": "✅ {{ .Email }}: Pengguna Telegram dihapus dengan berhasil.", + "enableSuccess": "✅ {{ .Email }}: Diaktifkan dengan berhasil.", + "disableSuccess": "✅ {{ .Email }}: Dinonaktifkan dengan berhasil.", + "askToAddUserId": "Konfigurasi Anda tidak ditemukan!\r\nSilakan minta admin Anda untuk menggunakan ChatID Telegram Anda dalam konfigurasi Anda.\r\n\r\nChatID Pengguna Anda: {{ .TgUserID }}", + "chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}", + "chooseInbound": "Pilih Inbound" + } }, "email": {} } diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index c3f6c4e..cfbf3e0 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -88,7 +88,8 @@ "noExpiry": "期限なし", "copyAllConfigs": "すべての設定をコピー", "copyAllConfigsCopied": "すべての設定をコピーしました", - "email": "メール" + "email": "メール", + "individualLinks": "個別リンク" }, "menu": { "theme": "テーマ", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "プローブ要求に使う HTTP メソッド。", "note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。" } - } + }, + "TGBotSettings": "Telegram Bot", + "notifications": "通知", + "notifyTime": { + "custom": "カスタム (crontab)", + "daily": "@daily — 毎日 00:00", + "every": "@every — 一定間隔で繰り返す", + "hourly": "@hourly — 1時間ごと", + "hours": "時間", + "interval": "間隔", + "minutes": "分", + "monthly": "@monthly — 毎月", + "seconds": "秒", + "unit": "単位", + "weekly": "@weekly — 毎週" + }, + "telegramAPIServer": "Telegram API サーバー", + "telegramAPIServerDesc": "使用するTelegram APIサーバー。空白の場合はデフォルトサーバーを使用する", + "telegramBotEnable": "Telegramボットを有効にする", + "telegramBotEnableDesc": "Telegramボット機能を有効にする", + "telegramBotLanguage": "Telegram Botの言語", + "telegramChatId": "管理者チャットID", + "telegramChatIdDesc": "Telegram管理者チャットID(複数の場合はカンマで区切る){'@'}userinfobotで取得するか、ボットで'/id'コマンドを使用して取得する", + "telegramNotifyTime": "通知時間", + "telegramNotifyTimeDesc": "Telegram ボットが定期レポートを送信する頻度です。プリセットの間隔を選ぶか、「カスタム」を選んで crontab 式を入力します。", + "telegramToken": "Telegram トークン", + "telegramTokenConfigured": "設定済み。現在のトークンを維持する場合は空欄のままにしてください。", + "telegramTokenDesc": "'{'@'}BotFather'から取得したTelegramボットトークン", + "telegramTokenPlaceholder": "設定済み - 置き換えるには新しいトークンを入力してください", + "testTgBot": "テストメッセージを送信", + "tgBotNotEnabled": "Telegramボットが有効になっていません", + "tgBotNotRunning": "Telegramボットが実行されていません", + "tgEventBusNotify": "Telegramイベント通知", + "tgEventBusNotifyDesc": "Telegram通知をトリガーするイベントを選択してください", + "tgNotifyBackup": "データベースバックアップ", + "tgNotifyBackupDesc": "レポート付きのデータベースバックアップファイルを送信", + "tgTestFailed": "Telegramのテストに失敗しました", + "tgTestSuccess": "Telegramにテストメッセージを送信しました" }, "xray": { "importRules": "ルールをインポート", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ IPレコードがありません!" + "noIpRecord": "❗ IPレコードがありません!", + "keyboardClosed": "❌ キーボードを閉じました!", + "noResult": "❗ 結果がありません!", + "noQuery": "❌ クエリが見つかりません!コマンドを再利用してください!", + "wentWrong": "❌ 何かがうまくいかなかった!", + "noInbounds": "❗ インバウンドが見つかりません!", + "unlimited": "♾ 無制限(リセット)", + "add": "追加", + "month": "月", + "months": "ヶ月", + "days": "日間", + "hours": "時間", + "minutes": "分", + "unknown": "不明", + "inbounds": "インバウンド", + "clients": "クライアント", + "offline": "🔴 オフライン", + "online": "🟢 オンライン", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ 操作成功!", + "restartFailed": "❗ 操作エラー。\r\n\r\nエラー: {{ .Error }}", + "xrayNotRunning": "❗ Xray Core は動作していません。", + "startDesc": "メインメニューを表示", + "helpDesc": "ボットのヘルプ", + "statusDesc": "ボットの状態を確認", + "idDesc": "Telegram IDを表示", + "usageDesc": "クライアント使用量を表示: /usage メール", + "inboundDesc": "インバウンド検索: /inbound 備考(管理者)", + "restartDesc": "Xray コアを再起動(管理者)", + "clearallDesc": "全クライアントのトラフィックをリセット(管理者)" + }, + "messages": { + "cpuThreshold": "CPU使用率は{{ .Percent }}%、しきい値{{ .Threshold }}%を超えました", + "selectUserFailed": "❌ ユーザーの選択に失敗しました!", + "userSaved": "✅ Telegramユーザーが保存されました。", + "loginSuccess": "✅ パネルに正常にログインしました。\r\n", + "loginFailed": "❗️ パネルのログインに失敗しました。\r\n", + "report": "🕰 定期報告:{{ .RunTime }}\r\n", + "datetime": "⏰ 日時:{{ .DateTime }}\r\n", + "hostname": "💻 ホスト: {{ .Hostname }}\r\n", + "version": "🚀 X-UI バージョン:{{ .Version }}\r\n", + "xrayVersion": "📡 Xray バージョン: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IP:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ サーバー稼働時間:{{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 サーバー負荷:{{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 トラフィック:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ ステータス: {{ .State }}\r\n", + "username": "👤 ユーザー名:{{ .Username }}\r\n", + "reason": "❗️ 理由:{{ .Reason }}\r\n", + "time": "⏰ 時間:{{ .Time }}\r\n", + "inbound": "📍 インバウンド: {{ .Remark }}\r\n", + "port": "🔌 ポート: {{ .Port }}\r\n", + "expire": "📅 有効期限:{{ .Time }}\r\n", + "expireIn": "📅 残り時間:{{ .Time }}\r\n", + "active": "💡 有効:{{ .Enable }}\r\n", + "enabled": "🚨 有効化済み:{{ .Enable }}\r\n", + "online": "🌐 接続ステータス:{{ .Status }}\r\n", + "lastOnline": "🔙 最終オンライン: {{ .Time }}\r\n", + "email": "📧 メール: {{ .Email }}\r\n", + "upload": "🔼 アップロード: ↑{{ .Upload }}\r\n", + "download": "🔽 ダウンロード: ↓{{ .Download }}\r\n", + "total": "📊 合計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Telegramユーザー:{{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 消耗済みの {{ .Type }} 数量:\r\n", + "onlinesCount": "🌐 オンラインクライアント:{{ .Count }}\r\n", + "disabled": "🛑 無効化:{{ .Disabled }}\r\n", + "depleteSoon": "🔜 間もなく消耗:{{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 バックアップ時間:{{ .Time }}\r\n", + "refreshedOn": "\r\n📋🔄 更新時間:{{ .Time }}\r\n\r\n", + "yes": "✅ はい", + "no": "❌ いいえ", + "received_email": "📧📥 メールが更新されました。", + "received_comment": "💬📥 コメントが更新されました。", + "email_prompt": "📧 デフォルトメール: {{ .ClientEmail }}\n\nメールを入力してください。", + "comment_prompt": "💬 デフォルトコメント: {{ .ClientComment }}\n\nコメントを入力してください。", + "cancel": "❌ プロセスがキャンセルされました!\n\nいつでも /start で再開できます。 🔄", + "error_add_client": "⚠️ エラー:\n\n {{ .error }}", + "using_default_value": "わかりました、デフォルト値を使用します。 😊", + "incorrect_input": "入力が無効です。\nフレーズはスペースなしで続けて入力してください。\n正しい例: aaaaaa\n間違った例: aaa aaa 🚫", + "AreYouSure": "本当にいいですか?🤔", + "SuccessResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功", + "FailedResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。", + "eventOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています", + "eventOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました", + "eventErrorDetail": "エラー: {{ .Error }}", + "eventDelayDetail": "遅延: {{ .Delay }}ms", + "eventXrayCrash": "Xrayがクラッシュしました", + "eventXrayCrashError": "エラー: {{ .Error }}", + "eventNodeDown": "ノード {{ .Name }} がダウンしています", + "eventNodeUp": "ノード {{ .Name }} が復旧しました", + "eventLoginFallback": "{{ .Source }} からのログインに失敗しました", + "memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました" + }, + "buttons": { + "closeKeyboard": "❌ キーボードを閉じる", + "cancel": "❌ キャンセル", + "cancelReset": "❌ リセットをキャンセル", + "cancelIpLimit": "❌ IP制限をキャンセル", + "confirmResetTraffic": "✅ トラフィックをリセットしますか?", + "confirmClearIps": "✅ IPをクリアしますか?", + "confirmRemoveTGUser": "✅ Telegramユーザーを削除しますか?", + "confirmToggle": "✅ ユーザーを有効/無効にしますか?", + "dbBackup": "データベースバックアップを取得", + "serverUsage": "サーバーの使用状況", + "getInbounds": "インバウンド情報を取得", + "depleteSoon": "間もなく消耗", + "clientUsage": "使用状況を取得", + "onlines": "オンラインクライアント", + "commands": "コマンド", + "refresh": "🔄 更新", + "clearIPs": "❌ IPをクリア", + "removeTGUser": "❌ Telegramユーザーを削除", + "selectTGUser": "👤 Telegramユーザーを選択", + "selectOneTGUser": "👤 1人のTelegramユーザーを選択:", + "resetTraffic": "📈 トラフィックをリセット", + "resetExpire": "📅 有効期限を変更", + "ipLog": "🔢 IPログ", + "ipLimit": "🔢 IP制限", + "setTGUser": "👤 Telegramユーザーを設定", + "toggle": "🔘 有効/無効", + "custom": "🔢 カスタム", + "confirmNumber": "✅ 確認: {{ .Num }}", + "confirmNumberAdd": "✅ 追加を確認:{{ .Num }}", + "limitTraffic": "🚧 トラフィック制限", + "getBanLogs": "禁止ログ", + "allClients": "すべてのクライアント", + "addClient": "クライアントを追加", + "submitDisable": "無効として送信 ☑️", + "submitEnable": "有効として送信 ✅", + "use_default": "🏷️ デフォルトを使用", + "change_email": "⚙️📧 メール", + "change_comment": "⚙️💬 コメント", + "ResetAllTraffics": "すべてのトラフィックをリセット", + "SortedTrafficUsageReport": "ソートされたトラフィック使用レポート" + }, + "answers": { + "successfulOperation": "✅ 成功!", + "errorOperation": "❗ 操作エラー。", + "getInboundsFailed": "❌ インバウンド情報の取得に失敗しました。", + "getClientsFailed": "❌ クライアントの取得に失敗しました。", + "canceled": "❌ {{ .Email }}:操作がキャンセルされました。", + "clientRefreshSuccess": "✅ {{ .Email }}:クライアントが正常に更新されました。", + "IpRefreshSuccess": "✅ {{ .Email }}:IPが正常に更新されました。", + "TGIdRefreshSuccess": "✅ {{ .Email }}:クライアントのTelegramユーザーが正常に更新されました。", + "resetTrafficSuccess": "✅ {{ .Email }}:トラフィックが正常にリセットされました。", + "setTrafficLimitSuccess": "✅ {{ .Email }}:トラフィック制限が正常に保存されました。", + "expireResetSuccess": "✅ {{ .Email }}:有効期限の日数が正常にリセットされました。", + "resetIpSuccess": "✅ {{ .Email }}:IP制限数が正常に保存されました:{{ .Count }}。", + "clearIpSuccess": "✅ {{ .Email }}:IPが正常にクリアされました。", + "getIpLog": "✅ {{ .Email }}:IPログの取得。", + "getUserInfo": "✅ {{ .Email }}:Telegramユーザー情報の取得。", + "removedTGUserSuccess": "✅ {{ .Email }}:Telegramユーザーが正常に削除されました。", + "enableSuccess": "✅ {{ .Email }}:正常に有効化されました。", + "disableSuccess": "✅ {{ .Email }}:正常に無効化されました。", + "askToAddUserId": "設定が見つかりませんでした!\r\n管理者に問い合わせて、設定にTelegramユーザーのChatIDを使用してください。\r\n\r\nあなたのユーザーChatID:{{ .TgUserID }}", + "chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択", + "chooseInbound": "インバウンドを選択" + } }, "email": {} } diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 83dac89..ca8ddaa 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -88,7 +88,8 @@ "noExpiry": "Sem validade", "copyAllConfigs": "Copiar Todas as Configurações", "copyAllConfigsCopied": "Todas as configurações copiadas", - "email": "Email" + "email": "Email", + "individualLinks": "Links individuais" }, "menu": { "theme": "Tema", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "Método usado nas requisições de probe.", "note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel." } - } + }, + "TGBotSettings": "Bot do Telegram", + "notifications": "Notificações", + "notifyTime": { + "custom": "Personalizado (crontab)", + "daily": "@daily — todos os dias às 00:00", + "every": "@every — repetir em um intervalo", + "hourly": "@hourly — a cada hora", + "hours": "Horas", + "interval": "Intervalo", + "minutes": "Minutos", + "monthly": "@monthly — todo mês", + "seconds": "Segundos", + "unit": "Unidade", + "weekly": "@weekly — toda semana" + }, + "telegramAPIServer": "Servidor API do Telegram", + "telegramAPIServerDesc": "O servidor API do Telegram a ser usado. Deixe em branco para usar o servidor padrão.", + "telegramBotEnable": "Ativar Bot do Telegram", + "telegramBotEnableDesc": "Ativa o bot do Telegram.", + "telegramBotLanguage": "Idioma do Bot do Telegram", + "telegramChatId": "ID de Chat do Administrador", + "telegramChatIdDesc": "O(s) ID(s) de Chat do Administrador no Telegram. (separado por vírgulas)(obtenha aqui {'@'}userinfobot) ou (use o comando '/id' no bot)", + "telegramNotifyTime": "Hora da Notificação", + "telegramNotifyTimeDesc": "Com que frequência o bot do Telegram envia relatórios periódicos. Escolha um intervalo predefinido ou selecione Personalizado para inserir uma expressão crontab.", + "telegramToken": "Token do Telegram", + "telegramTokenConfigured": "Configurado; deixe em branco para manter o token atual.", + "telegramTokenDesc": "O token do bot do Telegram obtido de '{'@'}BotFather'.", + "telegramTokenPlaceholder": "Configurado - insira um novo token para substituir", + "testTgBot": "Enviar mensagem de teste", + "tgBotNotEnabled": "O bot do Telegram não está ativado", + "tgBotNotRunning": "O bot do Telegram não está em execução", + "tgEventBusNotify": "Notificações de eventos no Telegram", + "tgEventBusNotifyDesc": "Selecione quais eventos disparam notificações no Telegram", + "tgNotifyBackup": "Backup do Banco de Dados", + "tgNotifyBackupDesc": "Enviar arquivo de backup do banco de dados junto com o relatório.", + "tgTestFailed": "Falha no teste do Telegram", + "tgTestSuccess": "Mensagem de teste enviada ao Telegram" }, "xray": { "importRules": "Importar regras", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ Nenhum registro de IP!" + "noIpRecord": "❗ Nenhum registro de IP!", + "keyboardClosed": "❌ Teclado fechado!", + "noResult": "❗ Nenhum resultado!", + "noQuery": "❌ Consulta não encontrada! Por favor, use o comando novamente!", + "wentWrong": "❌ Algo deu errado!", + "noInbounds": "❗ Nenhum inbound encontrado!", + "unlimited": "♾ Ilimitado (Reset)", + "add": "Adicionar", + "month": "Mês", + "months": "Meses", + "days": "Dias", + "hours": "Horas", + "minutes": "Minutos", + "unknown": "Desconhecido", + "inbounds": "Entradas", + "clients": "Clientes", + "offline": "🔴 Offline", + "online": "🟢 Online", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ Operação bem-sucedida!", + "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)", + "clearallDesc": "Zerar o tráfego de todos os clientes (admin)" + }, + "messages": { + "cpuThreshold": "A carga da CPU {{ .Percent }}% excede o limite de {{ .Threshold }}%", + "selectUserFailed": "❌ Erro na seleção do usuário!", + "userSaved": "✅ Usuário do Telegram salvo.", + "loginSuccess": "✅ Conectado ao painel com sucesso.\r\n", + "loginFailed": "❗️Tentativa de login no painel falhou.\r\n", + "report": "🕰 Relatórios agendados: {{ .RunTime }}\r\n", + "datetime": "⏰ Data&Hora: {{ .DateTime }}\r\n", + "hostname": "💻 Host: {{ .Hostname }}\r\n", + "version": "🚀 Versão 3X-UI: {{ .Version }}\r\n", + "xrayVersion": "📡 Versão Xray: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPs:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Tempo de atividade: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 Carga do sistema: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 Tráfego: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Status: {{ .State }}\r\n", + "username": "👤 Nome de usuário: {{ .Username }}\r\n", + "reason": "❗️ Motivo: {{ .Reason }}\r\n", + "time": "⏰ Hora: {{ .Time }}\r\n", + "inbound": "📍 Entrada: {{ .Remark }}\r\n", + "port": "🔌 Porta: {{ .Port }}\r\n", + "expire": "📅 Data de expiração: {{ .Time }}\r\n", + "expireIn": "📅 Expira em: {{ .Time }}\r\n", + "active": "💡 Ativo: {{ .Enable }}\r\n", + "enabled": "🚨 Ativado: {{ .Enable }}\r\n", + "online": "🌐 Status da conexão: {{ .Status }}\r\n", + "lastOnline": "🔙 Última vez online: {{ .Time }}\r\n", + "email": "📧 Email: {{ .Email }}\r\n", + "upload": "🔼 Upload: ↑{{ .Upload }}\r\n", + "download": "🔽 Download: ↓{{ .Download }}\r\n", + "total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Usuário do Telegram: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 Contagem de {{ .Type }} esgotado:\r\n", + "onlinesCount": "🌐 Clientes online: {{ .Count }}\r\n", + "disabled": "🛑 Desativado: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 Esgotar em breve: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Hora do backup: {{ .Time }}\r\n", + "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.", + "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 🚫", + "AreYouSure": "Você tem certeza? 🤔", + "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso", + "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes.", + "eventOutboundDown": "O outbound {{ .Tag }} está INATIVO", + "eventOutboundUp": "O outbound {{ .Tag }} está ATIVO", + "eventErrorDetail": "Erro: {{ .Error }}", + "eventDelayDetail": "Latência: {{ .Delay }}ms", + "eventXrayCrash": "O Xray FALHOU", + "eventXrayCrashError": "Erro: {{ .Error }}", + "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 }}%" + }, + "buttons": { + "closeKeyboard": "❌ Fechar teclado", + "cancel": "❌ Cancelar", + "cancelReset": "❌ Cancelar redefinição", + "cancelIpLimit": "❌ Cancelar limite de IP", + "confirmResetTraffic": "✅ Confirmar redefinição de tráfego?", + "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", + "refresh": "🔄 Atualizar", + "clearIPs": "❌ Limpar IPs", + "removeTGUser": "❌ Remover usuário do Telegram", + "selectTGUser": "👤 Selecionar usuário do Telegram", + "selectOneTGUser": "👤 Selecione um usuário do Telegram:", + "resetTraffic": "📈 Redefinir tráfego", + "resetExpire": "📅 Alterar data de expiração", + "ipLog": "🔢 Log de IP", + "ipLimit": "🔢 Limite de IP", + "setTGUser": "👤 Definir usuário do Telegram", + "toggle": "🔘 Ativar / Desativar", + "custom": "🔢 Personalizado", + "confirmNumber": "✅ Confirmar: {{ .Num }}", + "confirmNumberAdd": "✅ Confirmar adicionar: {{ .Num }}", + "limitTraffic": "🚧 Limite de tráfego", + "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" + }, + "answers": { + "successfulOperation": "✅ Operação bem-sucedida!", + "errorOperation": "❗ Erro na operação.", + "getInboundsFailed": "❌ Falha ao obter inbounds.", + "getClientsFailed": "❌ Falha ao obter clientes.", + "canceled": "❌ {{ .Email }}: Operação cancelada.", + "clientRefreshSuccess": "✅ {{ .Email }}: Cliente atualizado com sucesso.", + "IpRefreshSuccess": "✅ {{ .Email }}: IPs atualizados com sucesso.", + "TGIdRefreshSuccess": "✅ {{ .Email }}: Usuário do Telegram do cliente atualizado com sucesso.", + "resetTrafficSuccess": "✅ {{ .Email }}: Tráfego redefinido com sucesso.", + "setTrafficLimitSuccess": "✅ {{ .Email }}: Limite de tráfego salvo com sucesso.", + "expireResetSuccess": "✅ {{ .Email }}: Dias de expiração redefinidos com sucesso.", + "resetIpSuccess": "✅ {{ .Email }}: Limite de IP {{ .Count }} salvo com sucesso.", + "clearIpSuccess": "✅ {{ .Email }}: IPs limpos com sucesso.", + "getIpLog": "✅ {{ .Email }}: Obter log de IP.", + "getUserInfo": "✅ {{ .Email }}: Obter informações do usuário do Telegram.", + "removedTGUserSuccess": "✅ {{ .Email }}: Usuário do Telegram removido com sucesso.", + "enableSuccess": "✅ {{ .Email }}: Ativado com sucesso.", + "disableSuccess": "✅ {{ .Email }}: Desativado com sucesso.", + "askToAddUserId": "Sua configuração não foi encontrada!\r\nPeça ao seu administrador para usar seu Telegram ChatID em suas configurações.\r\n\r\nSeu ChatID: {{ .TgUserID }}", + "chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}", + "chooseInbound": "Escolha um Inbound" + } }, "email": {} } diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ec85b67..cba978c 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -88,7 +88,8 @@ "noExpiry": "Бессрочно", "copyAllConfigs": "Копировать все конфигурации", "copyAllConfigsCopied": "Все конфигурации скопированы", - "email": "Email" + "email": "Email", + "individualLinks": "Индивидуальные ссылки" }, "menu": { "theme": "Тема", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "Метод запросов при проверках.", "note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели." } - } + }, + "TGBotSettings": "Telegram-бот", + "notifications": "Уведомления", + "notifyTime": { + "custom": "Произвольный (crontab)", + "daily": "@daily — каждый день в 00:00", + "every": "@every — повторять с интервалом", + "hourly": "@hourly — каждый час", + "hours": "Часы", + "interval": "Интервал", + "minutes": "Минуты", + "monthly": "@monthly — каждый месяц", + "seconds": "Секунды", + "unit": "Единица", + "weekly": "@weekly — каждую неделю" + }, + "telegramAPIServer": "Telegram API Server", + "telegramAPIServerDesc": "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию.", + "telegramBotEnable": "Включить Telegram бота", + "telegramBotEnableDesc": "Доступ к функциям панели через Telegram-бота", + "telegramBotLanguage": "Язык Telegram-бота", + "telegramChatId": "User ID администратора бота", + "telegramChatIdDesc": "Один или несколько User ID администратора(-ов) Telegram-бота. Для получения User ID используйте {'@'}userinfobot или команду '/id' в боте.", + "telegramNotifyTime": "Частота уведомлений для администраторов от бота", + "telegramNotifyTimeDesc": "Как часто бот Telegram отправляет периодические отчёты. Выберите готовый интервал или «Произвольный», чтобы ввести выражение crontab.", + "telegramToken": "Telegram-токен", + "telegramTokenConfigured": "Настроен; оставьте пустым для сохранения текущего токена.", + "telegramTokenDesc": "Необходимо получить токен у менеджера ботов Telegram {'@'}botfather", + "telegramTokenPlaceholder": "Настроен - введите новый токен для замены", + "testTgBot": "Отправить тестовое сообщение", + "tgBotNotEnabled": "Telegram бот не включен", + "tgBotNotRunning": "Telegram бот не запущен", + "tgEventBusNotify": "Telegram уведомления о событиях", + "tgEventBusNotifyDesc": "Выберите события для Telegram уведомлений", + "tgNotifyBackup": "Резервное копирование базы данных", + "tgNotifyBackupDesc": "Отправлять уведомление с файлом резервной копии базы данных", + "tgTestFailed": "Тест Telegram не удался", + "tgTestSuccess": "Тестовое сообщение отправлено в Telegram" }, "xray": { "importRules": "Импорт правил", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ Нет записей об IP-адресе." + "noIpRecord": "❗ Нет записей об IP-адресе.", + "keyboardClosed": "❌ Клавиатура закрыта.", + "noResult": "❗ Нет результатов.", + "noQuery": "❌ Запрос не найден. Пожалуйста, повторите команду.", + "wentWrong": "❌ Что-то пошло не так...", + "noInbounds": "❗ У вас не настроено ни одного входящего подключения.", + "unlimited": "♾ Безлимит", + "add": "Добавить", + "month": "Месяц", + "months": "Месяцев", + "days": "Дней", + "hours": "Часов", + "minutes": "Минуты", + "unknown": "Неизвестно", + "inbounds": "Входящие", + "clients": "Клиенты", + "offline": "🔴 Не в сети", + "online": "🟢 В сети", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ Ядро Xray успешно перезапущено.", + "restartFailed": "❗ Ошибка при перезапуске Xray-core.\r\n\r\nОшибка: {{ .Error }}.", + "xrayNotRunning": "❗ Xray Core не запущен.", + "startDesc": "Показать главное меню", + "helpDesc": "Справка по боту", + "statusDesc": "Проверить статус бота", + "idDesc": "Показать ваш Telegram ID", + "usageDesc": "Показать трафик клиента: /usage email", + "inboundDesc": "Поиск входящих: /inbound имя (админ)", + "restartDesc": "Перезапустить ядро Xray (админ)", + "clearallDesc": "Сбросить трафик всех клиентов (админ)" + }, + "messages": { + "cpuThreshold": "Загрузка процессора составляет {{ .Percent }}%, что превышает пороговое значение {{ .Threshold }}%", + "selectUserFailed": "❌ Ошибка при выборе пользователя.", + "userSaved": "✅ Пользователь Telegram сохранен.", + "loginSuccess": "✅ Успешный вход в панель.\r\n", + "loginFailed": "❗️ Ошибка входа в панель.\r\n", + "report": "🕰 Запланированные отчеты: {{ .RunTime }}\r\n", + "datetime": "⏰ Дата и время: {{ .DateTime }}\r\n", + "hostname": "💻 Хост: {{ .Hostname }}\r\n", + "version": "🚀 Версия X-UI: {{ .Version }}\r\n", + "xrayVersion": "📡 Версия Xray: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IP:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Время работы сервера: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 Нагрузка сервера: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 Трафик: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Статус: {{ .State }}\r\n", + "username": "👤 Имя пользователя: {{ .Username }}\r\n", + "reason": "❗️ Причина: {{ .Reason }}\r\n", + "time": "⏰ Время: {{ .Time }}\r\n", + "inbound": "📍 Входящий: {{ .Remark }}\r\n", + "port": "🔌 Порт: {{ .Port }}\r\n", + "expire": "📅 Дата окончания: {{ .Time }}\r\n", + "expireIn": "📅 Окончание через: {{ .Time }}\r\n", + "active": "💡 Активен: {{ .Enable }}\r\n", + "enabled": "🚨 Активен: {{ .Enable }}\r\n", + "online": "🌐 Статус соединения: {{ .Status }}\r\n", + "lastOnline": "🔙 Был(а) в сети: {{ .Time }}\r\n", + "email": "📧 Email: {{ .Email }}\r\n", + "upload": "🔼 Загрузка: ↑{{ .Upload }}\r\n", + "download": "🔽 Загрузка: ↓{{ .Download }}\r\n", + "total": "📊 Всего: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Telegram User ID: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 Количество исчерпанных {{ .Type }}:\r\n", + "onlinesCount": "🌐 Клиентов онлайн: {{ .Count }}\r\n", + "disabled": "🛑 Отключено: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 Клиенты, у которых скоро исчерпание: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Время резервного копирования: {{ .Time }}\r\n", + "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Введите ваш комментарий.", + "cancel": "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄", + "error_add_client": "⚠️ Ошибка:\n\n {{ .error }}", + "using_default_value": "Используется значение по умолчанию👌", + "incorrect_input": "Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫", + "AreYouSure": "Вы уверены? 🤔", + "SuccessResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно", + "FailedResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Сброс трафика завершён для всех клиентов.", + "eventOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО", + "eventOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ", + "eventErrorDetail": "Ошибка: {{ .Error }}", + "eventDelayDetail": "Задержка: {{ .Delay }} мс", + "eventXrayCrash": "Сбой Xray", + "eventXrayCrashError": "Ошибка: {{ .Error }}", + "eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН", + "eventNodeUp": "Узел {{ .Name }} В СЕТИ", + "eventLoginFallback": "Неудачный вход с {{ .Source }}", + "memoryThreshold": "🔴 Использование памяти {{ .Percent }}% превышает пороговое значение {{ .Threshold }}%" + }, + "buttons": { + "closeKeyboard": "❌ Закрыть клавиатуру", + "cancel": "❌ Отмена", + "cancelReset": "❌ Отменить сброс", + "cancelIpLimit": "❌ Отменить лимит IP", + "confirmResetTraffic": "✅ Подтвердить сброс трафика?", + "confirmClearIps": "✅ Подтвердить очистку IP?", + "confirmRemoveTGUser": "✅ Подтвердить удаление пользователя Telegram?", + "confirmToggle": "✅ Подтвердить вкл/выкл пользователя?", + "dbBackup": "📂 Бэкап БД", + "serverUsage": "💻 Состояние сервера", + "getInbounds": "🔌 Входящие подключения", + "depleteSoon": "⚠️ Скоро конец", + "clientUsage": "Статистика клиента", + "onlines": "🟢 Онлайн", + "commands": "🖱️ Команды", + "refresh": "🔄 Обновить", + "clearIPs": "❌ Очистить IP", + "removeTGUser": "❌ Удалить пользователя Telegram", + "selectTGUser": "👤 Выбрать пользователя Telegram", + "selectOneTGUser": "👤 Выберите пользователя Telegram:", + "resetTraffic": "📈 Сбросить трафик", + "resetExpire": "📅 Изменить дату окончания", + "ipLog": "🔢 Лог IP", + "ipLimit": "🔢 Лимит IP", + "setTGUser": "👤 Установить пользователя Telegram", + "toggle": "🔘 Вкл./Выкл.", + "custom": "🔢 Своё", + "confirmNumber": "✅ Подтвердить: {{ .Num }}", + "confirmNumberAdd": "✅ Подтвердить добавление: {{ .Num }}", + "limitTraffic": "🚧 Лимит трафика", + "getBanLogs": "📄 Лог банов", + "allClients": "👥 Все клиенты", + "addClient": "➕ Новый клиент", + "submitDisable": "Добавить отключенным ☑️", + "submitEnable": "Добавить включенным ✅", + "use_default": "🏷️ Использовать по умолчанию", + "change_email": "⚙️📧 Email", + "change_comment": "⚙️💬 Комментарий", + "ResetAllTraffics": "Сбросить весь трафик", + "SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика" + }, + "answers": { + "successfulOperation": "✅ Успешно!", + "errorOperation": "❗ Ошибка в операции.", + "getInboundsFailed": "❌ Не удалось получить входящие подключения.", + "getClientsFailed": "❌ Не удалось получить клиентов.", + "canceled": "❌ {{ .Email }}: Операция отменена.", + "clientRefreshSuccess": "✅ {{ .Email }}: Клиент успешно обновлен.", + "IpRefreshSuccess": "✅ {{ .Email }}: IP-адреса успешно обновлены.", + "TGIdRefreshSuccess": "✅ {{ .Email }}: Пользователь Telegram клиента успешно обновлен.", + "resetTrafficSuccess": "✅ {{ .Email }}: Трафик успешно сброшен.", + "setTrafficLimitSuccess": "✅ {{ .Email }}: Лимит трафика успешно установлен.", + "expireResetSuccess": "✅ {{ .Email }}: Срок действия успешно сброшен.", + "resetIpSuccess": "✅ {{ .Email }}: Лимит IP ({{ .Count }}) успешно сохранен.", + "clearIpSuccess": "✅ {{ .Email }}: IP-адреса успешно очищены.", + "getIpLog": "✅ {{ .Email }}: Получен лог IP.", + "getUserInfo": "✅ {{ .Email }}: Получена информация о пользователе Telegram.", + "removedTGUserSuccess": "✅ {{ .Email }}: Пользователь Telegram успешно удален.", + "enableSuccess": "✅ {{ .Email }}: Включено успешно.", + "disableSuccess": "✅ {{ .Email }}: Отключено успешно.", + "askToAddUserId": "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: {{ .TgUserID }}", + "chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}", + "chooseInbound": "Выберите входящее подключение" + } }, "email": {} } diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index e5dc70e..30dd09b 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -88,7 +88,8 @@ "noExpiry": "Süresiz", "copyAllConfigs": "Tüm Yapılandırmaları Kopyala", "copyAllConfigsCopied": "Tüm yapılandırmalar kopyalandı", - "email": "E-posta" + "email": "E-posta", + "individualLinks": "Bireysel Bağlantılar" }, "menu": { "theme": "Tema", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "Probe isteklerinde kullanılan HTTP yöntemi.", "note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır." } - } + }, + "TGBotSettings": "Telegram Botu", + "notifications": "Bildirimler", + "notifyTime": { + "custom": "Özel (crontab)", + "daily": "@daily — her gün 00:00'da", + "every": "@every — bir aralıkla tekrarla", + "hourly": "@hourly — her saat", + "hours": "Saat", + "interval": "Aralık", + "minutes": "Dakika", + "monthly": "@monthly — her ay", + "seconds": "Saniye", + "unit": "Birim", + "weekly": "@weekly — her hafta" + }, + "telegramAPIServer": "Telegram API Sunucusu", + "telegramAPIServerDesc": "Kullanılacak Telegram API sunucusu. Varsayılan sunucuyu kullanmak için boş bırakın.", + "telegramBotEnable": "Telegram Botunu Etkinleştir", + "telegramBotEnableDesc": "Telegram botunu etkinleştirir.", + "telegramBotLanguage": "Telegram Bot Dili", + "telegramChatId": "Yönetici Sohbet Kimliği", + "telegramChatIdDesc": "Telegram Yönetici Sohbet Kimliği (Chat ID). Birden fazla ise virgülle ayırın. ({'@'}userinfobot'tan alabilirsiniz veya botta '/id' komutunu kullanabilirsiniz.)", + "telegramNotifyTime": "Bildirim Zamanı", + "telegramNotifyTimeDesc": "Telegram botunun periyodik raporları gönderme sıklığı. Hazır bir aralık seçin veya bir crontab ifadesi girmek için Özel'i seçin.", + "telegramToken": "Telegram Token", + "telegramTokenConfigured": "Yapılandırıldı; mevcut belirteci korumak için boş bırakın.", + "telegramTokenDesc": "'{'@'}BotFather'dan alınan Telegram bot token.", + "telegramTokenPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir belirteç girin", + "testTgBot": "Test Mesajı Gönder", + "tgBotNotEnabled": "Telegram botu etkin değil", + "tgBotNotRunning": "Telegram botu çalışmıyor", + "tgEventBusNotify": "Telegram Olay Bildirimleri", + "tgEventBusNotifyDesc": "Hangi olayların Telegram bildirimi tetikleyeceğini seçin", + "tgNotifyBackup": "Veritabanı Yedeği", + "tgNotifyBackupDesc": "Bir rapor ile birlikte veritabanı yedek dosyasını gönderir.", + "tgTestFailed": "Telegram testi başarısız oldu", + "tgTestSuccess": "Test mesajı Telegram'a gönderildi" }, "xray": { "save": "Kaydet", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ IP Kaydı Yok!" + "noIpRecord": "❗ IP Kaydı Yok!", + "keyboardClosed": "❌ Klavye kapatıldı!", + "noResult": "❗ Sonuç yok!", + "noQuery": "❌ Sorgu bulunamadı! Lütfen komutu tekrar kullanın!", + "wentWrong": "❌ Bir şeyler yanlış gitti!", + "noInbounds": "❗ Gelen bağlantı bulunamadı!", + "unlimited": "♾ Sınırsız (Sıfırla)", + "add": "Ekle", + "month": "Ay", + "months": "Aylar", + "days": "Günler", + "hours": "Saatler", + "minutes": "Dakikalar", + "unknown": "Bilinmeyen", + "inbounds": "Gelen Bağlantılar", + "clients": "Kullanıcılar", + "offline": "🔴 Çevrimdışı", + "online": "🟢 Çevrimiçi", + "commands": { + "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", + "restartUsage": "/restart", + "restartSuccess": "✅ İşlem başarılı!", + "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)", + "clearallDesc": "Tüm istemcilerin trafiğini sıfırla (yönetici)" + }, + "messages": { + "cpuThreshold": "CPU Yükü ({{ .Percent }}%), {{ .Threshold }}% eşiğini aşıyor", + "selectUserFailed": "❌ Kullanıcı seçiminde hata!", + "userSaved": "✅ Telegram Kullanıcısı kaydedildi.", + "loginSuccess": "✅ Panele başarıyla giriş yapıldı.\r\n", + "loginFailed": "❗️Panele giriş denemesi başarısız oldu.\r\n", + "report": "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n", + "datetime": "⏰ Tarih ve Saat: {{ .DateTime }}\r\n", + "hostname": "💻 Sunucu: {{ .Hostname }}", + "version": "🚀 3X-UI Sürümü: {{ .Version }}\r\n", + "xrayVersion": "📡 Xray Sürümü: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}", + "ipv4": "🌐 IPv4: {{ .IPv4 }}", + "ip": "🌐 IP: {{ .IP }}", + "ips": "🔢 IP'ler:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Çalışma Süresi: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 Sistem Yükü: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}", + "tcpCount": "🔹 TCP: {{ .Count }}", + "udpCount": "🔸 UDP: {{ .Count }}", + "traffic": "🚦 Trafik: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Durum: {{ .State }}\r\n", + "username": "👤 Kullanıcı Adı: {{ .Username }}\r\n", + "reason": "❗️ Sebep: {{ .Reason }}\r\n", + "time": "⏰ Zaman: {{ .Time }}\r\n", + "inbound": "📍 Gelen Bağlantı: {{ .Remark }}\r\n", + "port": "🔌 Bağlantı Noktası: {{ .Port }}", + "expire": "📅 Son Kullanma Tarihi: {{ .Time }}\r\n", + "expireIn": "📅 Sona Erecek: {{ .Time }}\r\n", + "active": "💡 Aktif: {{ .Enable }}\r\n", + "enabled": "🚨 Etkin: {{ .Enable }}\r\n", + "online": "🌐 Bağlantı durumu: {{ .Status }}\r\n", + "lastOnline": "🔙 Son Çevrimiçi: {{ .Time }}\r\n", + "email": "📧 E-posta: {{ .Email }}\r\n", + "upload": "🔼 Yükleme: ↑{{ .Upload }}\r\n", + "download": "🔽 İndirme: ↓{{ .Download }}\r\n", + "total": "📊 Toplam: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Telegram Kullanıcısı: {{ .TelegramID }}\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", + "depleteSoon": "🔜 Kotası Dolmak Üzere: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Yedekleme Zamanı: {{ .Time }}\r\n", + "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.", + "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 🚫", + "AreYouSure": "Emin misiniz? 🤔", + "SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı", + "FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı.", + "eventOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI", + "eventOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ", + "eventErrorDetail": "Hata: {{ .Error }}", + "eventDelayDetail": "Gecikme: {{ .Delay }}ms", + "eventXrayCrash": "Xray ÇÖKTÜ", + "eventXrayCrashError": "Hata: {{ .Error }}", + "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" + }, + "buttons": { + "closeKeyboard": "❌ Klavyeyi Kapat", + "cancel": "❌ İptal", + "cancelReset": "❌ Sıfırlamayı İptal Et", + "cancelIpLimit": "❌ IP Limitini İptal Et", + "confirmResetTraffic": "✅ Trafiği Sıfırlamayı Onayla?", + "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", + "refresh": "🔄 Yenile", + "clearIPs": "❌ IP'leri Temizle", + "removeTGUser": "❌ Telegram Kullanıcısını Kaldır", + "selectTGUser": "👤 Telegram Kullanıcısını Seç", + "selectOneTGUser": "👤 Bir Telegram Kullanıcısını Seçin:", + "resetTraffic": "📈 Trafiği Sıfırla", + "resetExpire": "📅 Son Kullanma Tarihini Değiştir", + "ipLog": "🔢 IP Günlüğü", + "ipLimit": "🔢 IP Limiti", + "setTGUser": "👤 Telegram Kullanıcısını Ayarla", + "toggle": "🔘 Etkinleştir / Devre Dışı Bırak", + "custom": "🔢 Özel", + "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", + "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" + }, + "answers": { + "successfulOperation": "✅ İşlem başarılı!", + "errorOperation": "❗ İşlemde hata.", + "getInboundsFailed": "❌ Gelen Bağlantılar alınamadı.", + "getClientsFailed": "❌ Kullanıcılar alınamadı.", + "canceled": "❌ {{ .Email }}: İşlem iptal edildi.", + "clientRefreshSuccess": "✅ {{ .Email }}: Kullanıcı başarıyla yenilendi.", + "IpRefreshSuccess": "✅ {{ .Email }}: IP'ler başarıyla yenilendi.", + "TGIdRefreshSuccess": "✅ {{ .Email }}: Kullanıcının Telegram Kimliği başarıyla yenilendi.", + "resetTrafficSuccess": "✅ {{ .Email }}: Trafik başarıyla sıfırlandı.", + "setTrafficLimitSuccess": "✅ {{ .Email }}: Trafik limiti başarıyla kaydedildi.", + "expireResetSuccess": "✅ {{ .Email }}: Son kullanma günleri başarıyla sıfırlandı.", + "resetIpSuccess": "✅ {{ .Email }}: IP limiti {{ .Count }} başarıyla kaydedildi.", + "clearIpSuccess": "✅ {{ .Email }}: IP'ler başarıyla temizlendi.", + "getIpLog": "✅ {{ .Email }}: IP Günlüğü alındı.", + "getUserInfo": "✅ {{ .Email }}: Telegram Kullanıcı Bilgisi alındı.", + "removedTGUserSuccess": "✅ {{ .Email }}: Telegram Kullanıcısı başarıyla kaldırıldı.", + "enableSuccess": "✅ {{ .Email }}: Başarıyla etkinleştirildi.", + "disableSuccess": "✅ {{ .Email }}: Başarıyla devre dışı bırakıldı.", + "askToAddUserId": "Yapılandırmanız bulunamadı!\r\nLütfen yöneticinizden Telegram Chat ID'nizi yapılandırmanıza eklemesini isteyin.\r\n\r\nSizin Chat ID'niz: {{ .TgUserID }}", + "chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin", + "chooseInbound": "Bir Gelen Bağlantı Seçin" + } }, "email": {} } diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 515643d..9366970 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -88,7 +88,8 @@ "noExpiry": "Без строку", "copyAllConfigs": "Копіювати всі конфігурації", "copyAllConfigsCopied": "Всі конфігурації скопійовано", - "email": "Email" + "email": "Email", + "individualLinks": "Окремі посилання" }, "menu": { "theme": "Тема", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "Метод запитів під час перевірок.", "note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі." } - } + }, + "TGBotSettings": "Telegram-бот", + "notifications": "Сповіщення", + "notifyTime": { + "custom": "Власний (crontab)", + "daily": "@daily — щодня о 00:00", + "every": "@every — повторювати з інтервалом", + "hourly": "@hourly — щогодини", + "hours": "Години", + "interval": "Інтервал", + "minutes": "Хвилини", + "monthly": "@monthly — щомісяця", + "seconds": "Секунди", + "unit": "Одиниця", + "weekly": "@weekly — щотижня" + }, + "telegramAPIServer": "Telegram API сервер", + "telegramAPIServerDesc": "Сервер Telegram API для використання. Залиште поле порожнім, щоб використовувати сервер за умовчанням.", + "telegramBotEnable": "Увімкнути Telegram Bot", + "telegramBotEnableDesc": "Вмикає бота Telegram.", + "telegramBotLanguage": "Мова Telegram-бота", + "telegramChatId": "Ідентифікатор чату адміністратора", + "telegramChatIdDesc": "Ідентифікатори чату адміністратора Telegram. (розділені комами) (отримайте тут {'@'}userinfobot) або (використовуйте команду '/id' у боті)", + "telegramNotifyTime": "Час сповіщення", + "telegramNotifyTimeDesc": "Як часто бот Telegram надсилає періодичні звіти. Виберіть готовий інтервал або «Власний», щоб ввести вираз crontab.", + "telegramToken": "Telegram-токен", + "telegramTokenConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний токен.", + "telegramTokenDesc": "Токен бота Telegram, отриманий від '{'@'}BotFather'.", + "telegramTokenPlaceholder": "Налаштовано — введіть новий токен для заміни", + "testTgBot": "Надіслати тестове повідомлення", + "tgBotNotEnabled": "Бот Telegram не увімкнено", + "tgBotNotRunning": "Бот Telegram не запущено", + "tgEventBusNotify": "Сповіщення про події в Telegram", + "tgEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Telegram", + "tgNotifyBackup": "Резервне копіювання бази даних", + "tgNotifyBackupDesc": "Надіслати файл резервної копії бази даних зі звітом.", + "tgTestFailed": "Тест Telegram не вдався", + "tgTestSuccess": "Тестове повідомлення надіслано в Telegram" }, "xray": { "save": "Зберегти", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ Немає запису IP!" + "noIpRecord": "❗ Немає запису IP!", + "keyboardClosed": "❌ Клавіатуру закрито!", + "noResult": "❗ Немає результату!", + "noQuery": "❌ Запит не знайдено! Будь ласка, використовуйте команду ще раз!", + "wentWrong": "❌ Щось пішло не так!", + "noInbounds": "❗ Вхідні не знайдені!", + "unlimited": "♾ Необмежено (Скинути)", + "add": "Додати", + "month": "Місяць", + "months": "Місяці", + "days": "Дні", + "hours": "Години", + "minutes": "Хвилини", + "unknown": "Невідомо", + "inbounds": "Вхідні", + "clients": "Клієнти", + "offline": "🔴 Не в мережі", + "online": "🟢 У мережі", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ Операція успішна!", + "restartFailed": "❗ Помилка в операції.\r\n\r\nПомилка: {{ .Error }}.", + "xrayNotRunning": "❗ Xray Core не запущений.", + "startDesc": "Показати головне меню", + "helpDesc": "Довідка по боту", + "statusDesc": "Перевірити статус бота", + "idDesc": "Показати ваш Telegram ID", + "usageDesc": "Показати трафік клієнта: /usage email", + "inboundDesc": "Пошук вхідних: /inbound назва (адмін)", + "restartDesc": "Перезапустити ядро Xray (адмін)", + "clearallDesc": "Скинути трафік усіх клієнтів (адмін)" + }, + "messages": { + "cpuThreshold": "Навантаження ЦП {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%", + "selectUserFailed": "❌ Помилка під час вибору користувача!", + "userSaved": "✅ Користувача Telegram збережено.", + "loginSuccess": "✅ Успішно ввійшли в панель\r\n", + "loginFailed": "❗️ Помилка входу в панель.\r\n", + "report": "🕰 Заплановані звіти: {{ .RunTime }}\r\n", + "datetime": "⏰ Дата й час: {{ .DateTime }}\r\n", + "hostname": "💻 Хост: {{ .Hostname }}\r\n", + "version": "🚀 3X-UI Версія: {{ .Version }}\r\n", + "xrayVersion": "📡 Xray Версія: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IP:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Час роботи: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 Завантаження системи: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 Трафік: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Статус: {{ .State }}\r\n", + "username": "👤 Ім'я користувача: {{ .Username }}\r\n", + "reason": "❗️ Причина: {{ .Reason }}\r\n", + "time": "⏰ Час: {{ .Time }}\r\n", + "inbound": "📍 Вхідний: {{ .Remark }}\r\n", + "port": "🔌 Порт: {{ .Port }}\r\n", + "expire": "📅 Дата закінчення: {{ .Time }}\r\n", + "expireIn": "📅 Термін дії: {{ .Time }}\r\n", + "active": "💡 Активний: {{ .Enable }}\r\n", + "enabled": "🚨 Увімкнено: {{ .Enable }}\r\n", + "online": "🌐 Стан підключення: {{ .Status }}\r\n", + "lastOnline": "🔙 Був(ла) онлайн: {{ .Time }}\r\n", + "email": "📧 Email: {{ .Email }}\r\n", + "upload": "🔼 Завантаження: ↑{{ .Upload }}\r\n", + "download": "🔽 Завантаження: ↓{{ .Download }}\r\n", + "total": "📊 Усього: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Користувач Telegram: {{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 Вичерпано кількість {{ .Type }} count:\r\n", + "onlinesCount": "🌐 Онлайн-клієнти: {{ .Count }}\r\n", + "disabled": "🛑 Вимкнено: {{ .Disabled }}\r\n", + "depleteSoon": "🔜 Скоро вичерпається: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Час резервного копіювання: {{ .Time }}\r\n", + "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Введіть ваш коментар.", + "cancel": "❌ Процес скасовано! \n\nВи можете знову розпочати, використовуючи /start у будь-який час. 🔄", + "error_add_client": "⚠️ Помилка:\n\n {{ .error }}", + "using_default_value": "Гаразд, залишу значення за замовчуванням. 😊", + "incorrect_input": "Ваш ввід невірний.\nФрази повинні бути без пробілів.\nПравильний приклад: aaaaaa\nНеправильний приклад: aaa aaa 🚫", + "AreYouSure": "Ви впевнені? 🤔", + "SuccessResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно", + "FailedResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів.", + "eventOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ", + "eventOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ", + "eventErrorDetail": "Помилка: {{ .Error }}", + "eventDelayDetail": "Затримка: {{ .Delay }} мс", + "eventXrayCrash": "Стався збій Xray", + "eventXrayCrashError": "Помилка: {{ .Error }}", + "eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ", + "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ", + "eventLoginFallback": "Невдала спроба входу з {{ .Source }}", + "memoryThreshold": "Використання пам'яті {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%" + }, + "buttons": { + "closeKeyboard": "❌ Закрити клавіатуру", + "cancel": "❌ Скасувати", + "cancelReset": "❌ Скасувати скидання", + "cancelIpLimit": "❌ Скасувати обмеження IP", + "confirmResetTraffic": "✅ Підтвердити скидання трафіку?", + "confirmClearIps": "✅ Підтвердити очищення IP-адрес?", + "confirmRemoveTGUser": "✅ Підтвердити видалення користувача Telegram?", + "confirmToggle": "✅ Підтвердити ввімкнути/вимкнути користувача?", + "dbBackup": "Отримати резервну копію БД", + "serverUsage": "Використання сервера", + "getInbounds": "Отримати вхідні", + "depleteSoon": "Скоро вичерпати", + "clientUsage": "Отримати використання", + "onlines": "Онлайн-клієнти", + "commands": "Команди", + "refresh": "🔄 Оновити", + "clearIPs": "❌ Очистити IP-адреси", + "removeTGUser": "❌ Видалити користувача Telegram", + "selectTGUser": "👤 Виберіть користувача Telegram", + "selectOneTGUser": "👤 Виберіть користувача Telegram:", + "resetTraffic": "📈 Скинути трафік", + "resetExpire": "📅 Змінити термін дії", + "ipLog": "🔢 IP журнал", + "ipLimit": "🔢 IP Ліміт", + "setTGUser": "👤 Встановити користувача Telegram", + "toggle": "🔘 Увімкнути / Вимкнути", + "custom": "🔢 Своє", + "confirmNumber": "✅ Підтвердити: {{ .Num }}", + "confirmNumberAdd": "✅ Підтвердити додавання: {{ .Num }}", + "limitTraffic": "🚧 Ліміт трафіку", + "getBanLogs": "Отримати журнали заборон", + "allClients": "Всі Клієнти", + "addClient": "Додати клієнта", + "submitDisable": "Надіслати як вимкнено ☑️", + "submitEnable": "Надіслати як увімкнено ✅", + "use_default": "🏷️ Використати типове", + "change_email": "⚙️📧 Email", + "change_comment": "⚙️💬 Коментар", + "ResetAllTraffics": "Скинути весь трафік", + "SortedTrafficUsageReport": "Відсортований звіт про використання трафіку" + }, + "answers": { + "successfulOperation": "✅ Операція успішна!", + "errorOperation": "❗ Помилка в роботі.", + "getInboundsFailed": "❌ Не вдалося отримати вхідні повідомлення.", + "getClientsFailed": "❌ Не вдалося отримати клієнтів.", + "canceled": "❌ {{ .Email }}: Операцію скасовано.", + "clientRefreshSuccess": "✅ {{ .Email }}: Клієнт успішно оновлено.", + "IpRefreshSuccess": "✅ {{ .Email }}: IP-адреси успішно оновлено.", + "TGIdRefreshSuccess": "✅ {{ .Email }}: Користувач Telegram клієнта успішно оновлено.", + "resetTrafficSuccess": "✅ {{ .Email }}: Трафік скинуто успішно.", + "setTrafficLimitSuccess": "✅ {{ .Email }}: Ліміт трафіку успішно збережено.", + "expireResetSuccess": "✅ {{ .Email }}: Успішно скинуто дні закінчення терміну дії.", + "resetIpSuccess": "✅ {{ .Email }}: IP обмеження {{ .Count }} успішно збережено.", + "clearIpSuccess": "✅ {{ .Email }}: IP успішно очищено.", + "getIpLog": "✅ {{ .Email }}: Отримати IP-журнал.", + "getUserInfo": "✅ {{ .Email }}: Отримати інформацію про користувача Telegram.", + "removedTGUserSuccess": "✅ {{ .Email }}: Користувача Telegram видалено успішно.", + "enableSuccess": "✅ {{ .Email }}: Увімкнути успішно.", + "disableSuccess": "✅ {{ .Email }}: Успішно вимкнено.", + "askToAddUserId": "Вашу конфігурацію не знайдено!\r\nБудь ласка, попросіть свого адміністратора використовувати ваш ідентифікатор Telegram у вашій конфігурації.\r\n\r\nВаш ідентифікатор користувача: {{ .TgUserID }}", + "chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}", + "chooseInbound": "Виберіть Вхідний" + } }, "email": {} } diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index f8dcb47..507ca6b 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -88,7 +88,8 @@ "noExpiry": "Không hết hạn", "copyAllConfigs": "Sao chép tất cả cấu hình", "copyAllConfigsCopied": "Đã sao chép tất cả cấu hình", - "email": "Email" + "email": "Email", + "individualLinks": "Liên kết riêng lẻ" }, "menu": { "theme": "Chủ đề", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "Phương thức dùng cho yêu cầu probe.", "note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển." } - } + }, + "TGBotSettings": "Bot Telegram", + "notifications": "Thông báo", + "notifyTime": { + "custom": "Tùy chỉnh (crontab)", + "daily": "@daily — mỗi ngày lúc 00:00", + "every": "@every — lặp lại theo khoảng thời gian", + "hourly": "@hourly — mỗi giờ", + "hours": "Giờ", + "interval": "Khoảng thời gian", + "minutes": "Phút", + "monthly": "@monthly — mỗi tháng", + "seconds": "Giây", + "unit": "Đơn vị", + "weekly": "@weekly — mỗi tuần" + }, + "telegramAPIServer": "Máy chủ API Telegram", + "telegramAPIServerDesc": "Máy chủ API Telegram để sử dụng. Để trống để sử dụng máy chủ mặc định.", + "telegramBotEnable": "Bật Bot Telegram", + "telegramBotEnableDesc": "Kết nối với các tính năng của bảng điều khiển này thông qua bot Telegram", + "telegramBotLanguage": "Ngôn ngữ của Bot Telegram", + "telegramChatId": "Chat ID Telegram của quản trị viên", + "telegramChatIdDesc": "Nhiều Chat ID phân tách bằng dấu phẩy. Sử dụng {'@'}userinfobot hoặc sử dụng lệnh '/id' trong bot để lấy Chat ID của bạn.", + "telegramNotifyTime": "Thời gian thông báo của bot Telegram", + "telegramNotifyTimeDesc": "Tần suất bot Telegram gửi báo cáo định kỳ. Chọn một khoảng thời gian có sẵn, hoặc chọn Tùy chỉnh để nhập biểu thức crontab.", + "telegramToken": "Token Telegram", + "telegramTokenConfigured": "Đã cấu hình; để trống để giữ token hiện tại.", + "telegramTokenDesc": "Bạn phải nhận token từ quản lý bot Telegram {'@'}botfather", + "telegramTokenPlaceholder": "Đã cấu hình - nhập token mới để thay thế", + "testTgBot": "Gửi tin nhắn thử nghiệm", + "tgBotNotEnabled": "Bot Telegram chưa được bật", + "tgBotNotRunning": "Bot Telegram không hoạt động", + "tgEventBusNotify": "Thông báo sự kiện qua Telegram", + "tgEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua Telegram", + "tgNotifyBackup": "Sao lưu Cơ sở dữ liệu", + "tgNotifyBackupDesc": "Bao gồm tệp sao lưu cơ sở dữ liệu với thông báo báo cáo.", + "tgTestFailed": "Thử nghiệm Telegram thất bại", + "tgTestSuccess": "Đã gửi tin nhắn thử nghiệm tới Telegram" }, "xray": { "importRules": "Nhập quy tắc", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ Không có bản ghi IP!" + "noIpRecord": "❗ Không có bản ghi IP!", + "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!", + "noInbounds": "❗ Không tìm thấy inbound!", + "unlimited": "♾ Không giới hạn (Đặt lại)", + "add": "Thêm", + "month": "Tháng", + "months": "Tháng", + "days": "Ngày", + "hours": "Giờ", + "minutes": "Phút", + "unknown": "Không xác định", + "inbounds": "Inbound", + "clients": "Client", + "offline": "🔴 Ngoại tuyến", + "online": "🟢 Trực tuyến", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ Hoạt động thành công!", + "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ị)", + "clearallDesc": "Đặt lại lưu lượng mọi khách hàng (quản trị)" + }, + "messages": { + "cpuThreshold": "Sử dụng CPU {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%", + "selectUserFailed": "❌ Lỗi khi chọn người dùng!", + "userSaved": "✅ Người dùng Telegram đã được lưu.", + "loginSuccess": "✅ Đăng nhập thành công vào bảng điều khiển.\r\n", + "loginFailed": "❗️ Đăng nhập vào bảng điều khiển thất bại.\r\n", + "report": "🕰 Báo cáo định kỳ: {{ .RunTime }}\r\n", + "datetime": "⏰ Ngày-Giờ: {{ .DateTime }}\r\n", + "hostname": "💻 Host: {{ .Hostname }}\r\n", + "version": "🚀 Phiên bản X-UI: {{ .Version }}\r\n", + "xrayVersion": "📡 Phiên bản Xray: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPs:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ Thời gian hoạt động của máy chủ: {{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 Tải máy chủ: {{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 Lưu lượng: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ Trạng thái: {{ .State }}\r\n", + "username": "👤 Tên người dùng: {{ .Username }}\r\n", + "reason": "❗️ Lý do: {{ .Reason }}\r\n", + "time": "⏰ Thời gian: {{ .Time }}\r\n", + "inbound": "📍 Inbound: {{ .Remark }}\r\n", + "port": "🔌 Cổng: {{ .Port }}\r\n", + "expire": "📅 Ngày hết hạn: {{ .Time }}\r\n", + "expireIn": "📅 Hết hạn sau: {{ .Time }}\r\n", + "active": "💡 Đang hoạt động: {{ .Enable }}\r\n", + "enabled": "🚨 Đã bật: {{ .Enable }}\r\n", + "online": "🌐 Trạng thái kết nối: {{ .Status }}\r\n", + "lastOnline": "🔙 Lần online gần nhất: {{ .Time }}\r\n", + "email": "📧 Email: {{ .Email }}\r\n", + "upload": "🔼 Tải lên: ↑{{ .Upload }}\r\n", + "download": "🔽 Tải xuống: ↓{{ .Download }}\r\n", + "total": "📊 Tổng: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 Người dùng Telegram: {{ .TelegramID }}\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", + "depleteSoon": "🔜 Sắp cạn kiệt: {{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 Thời gian sao lưu: {{ .Time }}\r\n", + "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.", + "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 🚫", + "AreYouSure": "Bạn có chắc không? 🤔", + "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công", + "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng.", + "eventOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG", + "eventOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG", + "eventErrorDetail": "Lỗi: {{ .Error }}", + "eventDelayDetail": "Độ trễ: {{ .Delay }}ms", + "eventXrayCrash": "Xray GẶP SỰ CỐ", + "eventXrayCrashError": "Lỗi: {{ .Error }}", + "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 }}%" + }, + "buttons": { + "closeKeyboard": "❌ Đóng Bàn Phím", + "cancel": "❌ Hủy", + "cancelReset": "❌ Hủy Đặt Lại", + "cancelIpLimit": "❌ Hủy Giới Hạn IP", + "confirmResetTraffic": "✅ Xác Nhận Đặt Lại Lưu Lượng?", + "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", + "refresh": "🔄 Cập Nhật", + "clearIPs": "❌ Xóa IP", + "removeTGUser": "❌ Xóa Người Dùng Telegram", + "selectTGUser": "👤 Chọn Người Dùng Telegram", + "selectOneTGUser": "👤 Chọn một người dùng telegram:", + "resetTraffic": "📈 Đặt Lại Lưu Lượng", + "resetExpire": "📅 Thay đổi ngày hết hạn", + "ipLog": "🔢 Nhật ký địa chỉ IP", + "ipLimit": "🔢 Giới Hạn địa chỉ IP", + "setTGUser": "👤 Đặt Người Dùng Telegram", + "toggle": "🔘 Bật / Tắt", + "custom": "🔢 Tùy chỉnh", + "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", + "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" + }, + "answers": { + "successfulOperation": "✅ Thành công!", + "errorOperation": "❗ Lỗi Trong Quá Trình Thực Hiện.", + "getInboundsFailed": "❌ Không Thể Lấy Được Inbounds", + "getClientsFailed": "❌ Không thể lấy khách hàng.", + "canceled": "❌ {{ .Email }} : Thao Tác Đã Bị Hủy.", + "clientRefreshSuccess": "✅ {{ .Email }} : Cập Nhật Thành Công Cho Khách Hàng.", + "IpRefreshSuccess": "✅ {{ .Email }} : Cập Nhật Thành Công Cho IPs.", + "TGIdRefreshSuccess": "✅ {{ .Email }} : Cập Nhật Thành Công Cho Người Dùng Telegram.", + "resetTrafficSuccess": "✅ {{ .Email }} : Đặt Lại Lưu Lượng Thành Công.", + "setTrafficLimitSuccess": "✅ {{ .Email }} : Đã lưu thành công giới hạn lưu lượng.", + "expireResetSuccess": "✅ {{ .Email }} : Đặt Lại Ngày Hết Hạn Thành Công.", + "resetIpSuccess": "✅ {{ .Email }} : Giới Hạn IP {{ .Count }} Đã Được Lưu Thành Công.", + "clearIpSuccess": "✅ {{ .Email }} : IP Đã Được Xóa Thành Công.", + "getIpLog": "✅ {{ .Email }} : Lấy nhật ký IP Thành Công.", + "getUserInfo": "✅ {{ .Email }} : Lấy Thông Tin Người Dùng Telegram Thành Công.", + "removedTGUserSuccess": "✅ {{ .Email }} : Người Dùng Telegram Đã Được Xóa Thành Công.", + "enableSuccess": "✅ {{ .Email }} : Đã Bật Thành Công.", + "disableSuccess": "✅ {{ .Email }} : Đã Tắt Thành Công.", + "askToAddUserId": "Cấu hình của bạn không được tìm thấy!\r\nVui lòng yêu cầu Quản trị viên sử dụng ID người dùng telegram của bạn trong cấu hình của bạn.\r\n\r\nID người dùng của bạn: {{ .TgUserID }}", + "chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}", + "chooseInbound": "Chọn một Inbound" + } }, "email": {} } diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 5f06f76..093ca5a 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -88,7 +88,8 @@ "noExpiry": "无到期", "copyAllConfigs": "复制全部配置", "copyAllConfigsCopied": "已复制全部配置", - "email": "邮箱" + "email": "邮箱", + "individualLinks": "单独链接" }, "menu": { "theme": "主题", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "探活请求使用的 HTTP 方法。", "note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。" } - } + }, + "TGBotSettings": "Telegram 机器人", + "notifications": "通知", + "notifyTime": { + "custom": "自定义 (crontab)", + "daily": "@daily — 每天 00:00", + "every": "@every — 按间隔重复", + "hourly": "@hourly — 每小时", + "hours": "小时", + "interval": "间隔", + "minutes": "分钟", + "monthly": "@monthly — 每月", + "seconds": "秒", + "unit": "单位", + "weekly": "@weekly — 每周" + }, + "telegramAPIServer": "Telegram API 服务器", + "telegramAPIServerDesc": "要使用的 Telegram API 服务器。留空以使用默认服务器。", + "telegramBotEnable": "启用 Telegram 机器人", + "telegramBotEnableDesc": "启用 Telegram 机器人功能", + "telegramBotLanguage": "Telegram 机器人语言", + "telegramChatId": "管理员聊天 ID", + "telegramChatIdDesc": "Telegram 管理员聊天 ID (多个以逗号分隔)(可通过 {'@'}userinfobot 获取,或在机器人中使用 '/id' 命令获取)", + "telegramNotifyTime": "通知时间", + "telegramNotifyTimeDesc": "Telegram 机器人发送周期性报告的频率。选择预设间隔,或选择“自定义”以输入 crontab 表达式。", + "telegramToken": "Telegram Token", + "telegramTokenConfigured": "已配置;留空则保留当前令牌。", + "telegramTokenDesc": "从 '{'@'}BotFather' 获取的 Telegram 机器人令牌", + "telegramTokenPlaceholder": "已配置——输入新令牌以替换", + "testTgBot": "发送测试消息", + "tgBotNotEnabled": "Telegram 机器人未启用", + "tgBotNotRunning": "Telegram 机器人未运行", + "tgEventBusNotify": "Telegram 事件通知", + "tgEventBusNotifyDesc": "选择触发 Telegram 通知的事件", + "tgNotifyBackup": "数据库备份", + "tgNotifyBackupDesc": "发送带有报告的数据库备份文件", + "tgTestFailed": "Telegram 测试失败", + "tgTestSuccess": "测试消息已发送至 Telegram" }, "xray": { "importRules": "导入规则", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ 没有 IP 记录!" + "noIpRecord": "❗ 没有 IP 记录!", + "keyboardClosed": "❌ 自定义键盘已关闭!", + "noResult": "❗ 没有结果!", + "noQuery": "❌ 未找到查询!请再次使用该命令!", + "wentWrong": "❌ 出了点问题!", + "noInbounds": "❗ 未找到入站!", + "unlimited": "♾ 无限(重置)", + "add": "添加", + "month": "月", + "months": "月", + "days": "天", + "hours": "小时", + "minutes": "分钟", + "unknown": "未知", + "inbounds": "入站", + "clients": "客户端", + "offline": "🔴 离线", + "online": "🟢 在线", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ 操作成功!", + "restartFailed": "❗ 操作错误。\r\n\r\n错误: {{ .Error }}.", + "xrayNotRunning": "❗ Xray Core 未运行。", + "startDesc": "显示主菜单", + "helpDesc": "机器人帮助", + "statusDesc": "检查机器人状态", + "idDesc": "显示您的 Telegram ID", + "usageDesc": "查看客户端用量:/usage 邮箱", + "inboundDesc": "搜索入站:/inbound 备注(管理员)", + "restartDesc": "重启 Xray 内核(管理员)", + "clearallDesc": "重置所有客户端流量(管理员)" + }, + "messages": { + "cpuThreshold": "CPU 使用率为 {{ .Percent }}%,超过阈值 {{ .Threshold }}%", + "selectUserFailed": "❌ 用户选择错误!", + "userSaved": "✅ 电报用户已保存。", + "loginSuccess": "✅ 成功登录到面板。\r\n", + "loginFailed": "❗️ 面板登录失败。\r\n", + "report": "🕰 定时报告:{{ .RunTime }}\r\n", + "datetime": "⏰ 日期时间:{{ .DateTime }}\r\n", + "hostname": "💻 主机: {{ .Hostname }}\r\n", + "version": "🚀 X-UI 版本:{{ .Version }}\r\n", + "xrayVersion": "📡 Xray 版本: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPs:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ 服务器运行时间:{{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 服务器负载:{{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ 状态: {{ .State }}\r\n", + "username": "👤 用户名:{{ .Username }}\r\n", + "reason": "❗️ 原因:{{ .Reason }}\r\n", + "time": "⏰ 时间:{{ .Time }}\r\n", + "inbound": "📍 入站: {{ .Remark }}\r\n", + "port": "🔌 端口: {{ .Port }}\r\n", + "expire": "📅 过期日期:{{ .Time }}\r\n", + "expireIn": "📅 剩余时间:{{ .Time }}\r\n", + "active": "💡 激活:{{ .Enable }}\r\n", + "enabled": "🚨 已启用:{{ .Enable }}\r\n", + "online": "🌐 连接状态:{{ .Status }}\r\n", + "lastOnline": "🔙 上次在线: {{ .Time }}\r\n", + "email": "📧 邮箱: {{ .Email }}\r\n", + "upload": "🔼 上传: ↑{{ .Upload }}\r\n", + "download": "🔽 下载: ↓{{ .Download }}\r\n", + "total": "📊 总计: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 电报用户:{{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 耗尽的 {{ .Type }} 数量:\r\n", + "onlinesCount": "🌐 在线客户:{{ .Count }}\r\n", + "disabled": "🛑 禁用:{{ .Disabled }}\r\n", + "depleteSoon": "🔜 即将耗尽:{{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 备份时间:{{ .Time }}\r\n", + "refreshedOn": "\r\n📋🔄 刷新时间:{{ .Time }}\r\n\r\n", + "yes": "✅ 是的", + "no": "❌ 否", + "received_email": "📧📥 邮箱已更新。", + "received_comment": "💬📥 评论已更新。", + "email_prompt": "📧 默认邮箱: {{ .ClientEmail }}\n\n请输入您的邮箱。", + "comment_prompt": "💬 默认评论: {{ .ClientComment }}\n\n请输入您的评论。", + "cancel": "❌ 进程已取消!\n\n您可以随时使用 /start 重新开始。 🔄", + "error_add_client": "⚠️ 错误:\n\n {{ .error }}", + "using_default_value": "好的,我会使用默认值。 😊", + "incorrect_input": "您的输入无效。\n短语应连续输入,不能有空格。\n正确示例: aaaaaa\n错误示例: aaa aaa 🚫", + "AreYouSure": "你确定吗?🤔", + "SuccessResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功", + "FailedResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 所有客户的流量重置已完成。", + "eventOutboundDown": "出站 {{ .Tag }} 已断开", + "eventOutboundUp": "出站 {{ .Tag }} 已恢复", + "eventErrorDetail": "错误:{{ .Error }}", + "eventDelayDetail": "延迟:{{ .Delay }} 毫秒", + "eventXrayCrash": "Xray 已崩溃", + "eventXrayCrashError": "错误:{{ .Error }}", + "eventNodeDown": "节点 {{ .Name }} 已离线", + "eventNodeUp": "节点 {{ .Name }} 已上线", + "eventLoginFallback": "来自 {{ .Source }} 的登录失败", + "memoryThreshold": "内存使用率 {{ .Percent }}% 超过阈值 {{ .Threshold }}%" + }, + "buttons": { + "closeKeyboard": "❌ 关闭键盘", + "cancel": "❌ 取消", + "cancelReset": "❌ 取消重置", + "cancelIpLimit": "❌ 取消 IP 限制", + "confirmResetTraffic": "✅ 确认重置流量?", + "confirmClearIps": "✅ 确认清除 IP?", + "confirmRemoveTGUser": "✅ 确认移除 Telegram 用户?", + "confirmToggle": "✅ 确认启用/禁用用户?", + "dbBackup": "获取数据库备份", + "serverUsage": "服务器使用情况", + "getInbounds": "获取入站信息", + "depleteSoon": "即将耗尽", + "clientUsage": "获取使用情况", + "onlines": "在线客户端", + "commands": "命令", + "refresh": "🔄 刷新", + "clearIPs": "❌ 清除 IP", + "removeTGUser": "❌ 移除 Telegram 用户", + "selectTGUser": "👤 选择 Telegram 用户", + "selectOneTGUser": "👤 选择一个 Telegram 用户:", + "resetTraffic": "📈 重置流量", + "resetExpire": "📅 更改到期日期", + "ipLog": "🔢 IP 日志", + "ipLimit": "🔢 IP 限制", + "setTGUser": "👤 设置 Telegram 用户", + "toggle": "🔘 启用/禁用", + "custom": "🔢 自定义", + "confirmNumber": "✅ 确认: {{ .Num }}", + "confirmNumberAdd": "✅ 确认添加:{{ .Num }}", + "limitTraffic": "🚧 流量限制", + "getBanLogs": "禁止日志", + "allClients": "所有客户", + "addClient": "添加客户", + "submitDisable": "提交为禁用 ☑️", + "submitEnable": "提交为启用 ✅", + "use_default": "🏷️ 使用默认", + "change_email": "⚙️📧 邮箱", + "change_comment": "⚙️💬 评论", + "ResetAllTraffics": "重置所有流量", + "SortedTrafficUsageReport": "排序的流量使用报告" + }, + "answers": { + "successfulOperation": "✅ 成功!", + "errorOperation": "❗ 操作错误。", + "getInboundsFailed": "❌ 获取入站信息失败。", + "getClientsFailed": "❌ 获取客户失败。", + "canceled": "❌ {{ .Email }}:操作已取消。", + "clientRefreshSuccess": "✅ {{ .Email }}:客户端刷新成功。", + "IpRefreshSuccess": "✅ {{ .Email }}:IP 刷新成功。", + "TGIdRefreshSuccess": "✅ {{ .Email }}:客户端的 Telegram 用户刷新成功。", + "resetTrafficSuccess": "✅ {{ .Email }}:流量已重置成功。", + "setTrafficLimitSuccess": "✅ {{ .Email }}: 流量限制保存成功。", + "expireResetSuccess": "✅ {{ .Email }}:过期天数已重置成功。", + "resetIpSuccess": "✅ {{ .Email }}:成功保存 IP 限制数量为 {{ .Count }}。", + "clearIpSuccess": "✅ {{ .Email }}:IP 已成功清除。", + "getIpLog": "✅ {{ .Email }}:获取 IP 日志。", + "getUserInfo": "✅ {{ .Email }}:获取 Telegram 用户信息。", + "removedTGUserSuccess": "✅ {{ .Email }}:Telegram 用户已成功移除。", + "enableSuccess": "✅ {{ .Email }}:已成功启用。", + "disableSuccess": "✅ {{ .Email }}:已成功禁用。", + "askToAddUserId": "未找到您的配置!\r\n请向管理员询问,在您的配置中使用您的 Telegram 用户 ChatID。\r\n\r\n您的用户 ChatID:{{ .TgUserID }}", + "chooseClient": "为入站 {{ .Inbound }} 选择一个客户", + "chooseInbound": "选择一个入站" + } }, "email": {} } diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index da7e1d9..a95031c 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -88,7 +88,8 @@ "noExpiry": "無到期", "copyAllConfigs": "複製全部配置", "copyAllConfigsCopied": "已複製全部配置", - "email": "電子郵件" + "email": "電子郵件", + "individualLinks": "個別連結" }, "menu": { "theme": "主題", @@ -1168,7 +1169,44 @@ "httpMethodDesc": "探活請求使用的 HTTP 方法。", "note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。" } - } + }, + "TGBotSettings": "Telegram 機器人", + "notifications": "通知", + "notifyTime": { + "custom": "自訂 (crontab)", + "daily": "@daily — 每天 00:00", + "every": "@every — 依間隔重複", + "hourly": "@hourly — 每小時", + "hours": "小時", + "interval": "間隔", + "minutes": "分鐘", + "monthly": "@monthly — 每月", + "seconds": "秒", + "unit": "單位", + "weekly": "@weekly — 每週" + }, + "telegramAPIServer": "Telegram API 伺服器", + "telegramAPIServerDesc": "要使用的 Telegram API 伺服器。留空以使用預設伺服器。", + "telegramBotEnable": "啟用 Telegram 機器人", + "telegramBotEnableDesc": "啟用 Telegram 機器人功能", + "telegramBotLanguage": "Telegram 機器人語言", + "telegramChatId": "管理員聊天 ID", + "telegramChatIdDesc": "Telegram 管理員聊天 ID (多個以逗號分隔)(可通過 {'@'}userinfobot 獲取,或在機器人中使用 '/id' 命令獲取)", + "telegramNotifyTime": "通知時間", + "telegramNotifyTimeDesc": "Telegram 機器人傳送週期性報告的頻率。選擇預設間隔,或選擇「自訂」以輸入 crontab 運算式。", + "telegramToken": "Telegram Token", + "telegramTokenConfigured": "已設定;留空以保留目前的權杖。", + "telegramTokenDesc": "從 '{'@'}BotFather' 獲取的 Telegram 機器人令牌", + "telegramTokenPlaceholder": "已設定 - 輸入新權杖以取代", + "testTgBot": "傳送測試訊息", + "tgBotNotEnabled": "Telegram 機器人未啟用", + "tgBotNotRunning": "Telegram 機器人未執行", + "tgEventBusNotify": "Telegram 事件通知", + "tgEventBusNotifyDesc": "選擇觸發 Telegram 通知的事件", + "tgNotifyBackup": "資料庫備份", + "tgNotifyBackupDesc": "傳送帶有報告的資料庫備份檔案", + "tgTestFailed": "Telegram 測試失敗", + "tgTestSuccess": "測試訊息已傳送至 Telegram" }, "xray": { "save": "儲存", @@ -1657,7 +1695,182 @@ } }, "tgbot": { - "noIpRecord": "❗ 沒有 IP 記錄!" + "noIpRecord": "❗ 沒有 IP 記錄!", + "keyboardClosed": "❌ 自定義鍵盤已關閉!", + "noResult": "❗ 沒有結果!", + "noQuery": "❌ 未找到查詢!請再次使用該命令!", + "wentWrong": "❌ 出了點問題!", + "noInbounds": "❗ 未找到入站!", + "unlimited": "♾ 無限(重置)", + "add": "添加", + "month": "月", + "months": "月", + "days": "天", + "hours": "小時", + "minutes": "分鐘", + "unknown": "未知", + "inbounds": "入站", + "clients": "客戶端", + "offline": "🔴 離線", + "online": "🟢 上線", + "commands": { + "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", + "restartUsage": "\r\n\r\n/restart", + "restartSuccess": "✅ 操作成功!", + "restartFailed": "❗ 操作錯誤。\r\n\r\n錯誤: {{ .Error }}.", + "xrayNotRunning": "❗ Xray Core 未運行。", + "startDesc": "顯示主選單", + "helpDesc": "機器人幫助", + "statusDesc": "檢查機器人狀態", + "idDesc": "顯示您的 Telegram ID", + "usageDesc": "查看客戶端用量:/usage 郵箱", + "inboundDesc": "搜尋入站:/inbound 備註(管理員)", + "restartDesc": "重啟 Xray 核心(管理員)", + "clearallDesc": "重置所有客戶端流量(管理員)" + }, + "messages": { + "cpuThreshold": "CPU 使用率為 {{ .Percent }}%,超過閾值 {{ .Threshold }}%", + "selectUserFailed": "❌ 使用者選擇錯誤!", + "userSaved": "✅ 電報使用者已儲存。", + "loginSuccess": "✅ 成功登入到面板。\r\n", + "loginFailed": "❗️ 面板登入失敗。\r\n", + "report": "🕰 定時報告:{{ .RunTime }}\r\n", + "datetime": "⏰ 日期時間:{{ .DateTime }}\r\n", + "hostname": "💻 主機: {{ .Hostname }}\r\n", + "version": "🚀 X-UI 版本:{{ .Version }}\r\n", + "xrayVersion": "📡 Xray 版本: {{ .XrayVersion }}\r\n", + "ipv6": "🌐 IPv6: {{ .IPv6 }}\r\n", + "ipv4": "🌐 IPv4: {{ .IPv4 }}\r\n", + "ip": "🌐 IP: {{ .IP }}\r\n", + "ips": "🔢 IPs:\r\n{{ .IPs }}\r\n", + "serverUpTime": "⏳ 伺服器執行時間:{{ .UpTime }} {{ .Unit }}\r\n", + "serverLoad": "📈 伺服器負載:{{ .Load1 }}, {{ .Load2 }}, {{ .Load3 }}\r\n", + "serverMemory": "📋 RAM: {{ .Current }}/{{ .Total }}\r\n", + "tcpCount": "🔹 TCP: {{ .Count }}\r\n", + "udpCount": "🔸 UDP: {{ .Count }}\r\n", + "traffic": "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n", + "xrayStatus": "ℹ️ 狀態: {{ .State }}\r\n", + "username": "👤 使用者名稱:{{ .Username }}\r\n", + "reason": "❗️ 原因:{{ .Reason }}\r\n", + "time": "⏰ 時間:{{ .Time }}\r\n", + "inbound": "📍 入站: {{ .Remark }}\r\n", + "port": "🔌 連接埠: {{ .Port }}\r\n", + "expire": "📅 過期日期:{{ .Time }}\r\n", + "expireIn": "📅 剩餘時間:{{ .Time }}\r\n", + "active": "💡 啟用:{{ .Enable }}\r\n", + "enabled": "🚨 已啟用:{{ .Enable }}\r\n", + "online": "🌐 連線狀態:{{ .Status }}\r\n", + "lastOnline": "🔙 上次上線: {{ .Time }}\r\n", + "email": "📧 電子郵件: {{ .Email }}\r\n", + "upload": "🔼 上傳: ↑{{ .Upload }}\r\n", + "download": "🔽 下載: ↓{{ .Download }}\r\n", + "total": "📊 總計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n", + "TGUser": "👤 電報使用者:{{ .TelegramID }}\r\n", + "exhaustedCount": "🚨 耗盡的 {{ .Type }} 數量:\r\n", + "onlinesCount": "🌐 線上客戶:{{ .Count }}\r\n", + "disabled": "🛑 禁用:{{ .Disabled }}\r\n", + "depleteSoon": "🔜 即將耗盡:{{ .Deplete }}\r\n\r\n", + "backupTime": "🗄 備份時間:{{ .Time }}\r\n", + "refreshedOn": "\r\n📋🔄 重新整理時間:{{ .Time }}\r\n\r\n", + "yes": "✅ 是的", + "no": "❌ 否", + "received_email": "📧📥 電子郵件已更新。", + "received_comment": "💬📥 評論已更新。", + "email_prompt": "📧 預設電子郵件: {{ .ClientEmail }}\n\n請輸入您的電子郵件。", + "comment_prompt": "💬 預設評論: {{ .ClientComment }}\n\n請輸入您的評論。", + "cancel": "❌ 程序已取消!\n\n您可以隨時使用 /start 重新開始。 🔄", + "error_add_client": "⚠️ 錯誤:\n\n {{ .error }}", + "using_default_value": "好的,我會使用預設值。 😊", + "incorrect_input": "您的輸入無效。\n短語應連續輸入,不能有空格。\n正確示例: aaaaaa\n錯誤示例: aaa aaa 🚫", + "AreYouSure": "你確定嗎?🤔", + "SuccessResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功", + "FailedResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]", + "FinishProcess": "🔚 所有客戶的流量重置已完成。", + "eventOutboundDown": "出站 {{ .Tag }} 已中斷", + "eventOutboundUp": "出站 {{ .Tag }} 已恢復", + "eventErrorDetail": "錯誤:{{ .Error }}", + "eventDelayDetail": "延遲:{{ .Delay }} 毫秒", + "eventXrayCrash": "Xray 已當機", + "eventXrayCrashError": "錯誤:{{ .Error }}", + "eventNodeDown": "節點 {{ .Name }} 已離線", + "eventNodeUp": "節點 {{ .Name }} 已上線", + "eventLoginFallback": "來自 {{ .Source }} 的登入失敗", + "memoryThreshold": "記憶體使用率 {{ .Percent }}% 超過閾值 {{ .Threshold }}%" + }, + "buttons": { + "closeKeyboard": "❌ 關閉鍵盤", + "cancel": "❌ 取消", + "cancelReset": "❌ 取消重置", + "cancelIpLimit": "❌ 取消 IP 限制", + "confirmResetTraffic": "✅ 確認重置流量?", + "confirmClearIps": "✅ 確認清除 IP?", + "confirmRemoveTGUser": "✅ 確認移除 Telegram 使用者?", + "confirmToggle": "✅ 確認啟用/禁用使用者?", + "dbBackup": "獲取資料庫備份", + "serverUsage": "伺服器使用情況", + "getInbounds": "獲取入站資訊", + "depleteSoon": "即將耗盡", + "clientUsage": "獲取使用情況", + "onlines": "線上客戶端", + "commands": "命令", + "refresh": "🔄 重新整理", + "clearIPs": "❌ 清除 IP", + "removeTGUser": "❌ 移除 Telegram 使用者", + "selectTGUser": "👤 選擇 Telegram 使用者", + "selectOneTGUser": "👤 選擇一個 Telegram 使用者:", + "resetTraffic": "📈 重置流量", + "resetExpire": "📅 更改到期日期", + "ipLog": "🔢 IP 日誌", + "ipLimit": "🔢 IP 限制", + "setTGUser": "👤 設定 Telegram 使用者", + "toggle": "🔘 啟用/禁用", + "custom": "🔢 自訂", + "confirmNumber": "✅ 確認: {{ .Num }}", + "confirmNumberAdd": "✅ 確認新增:{{ .Num }}", + "limitTraffic": "🚧 流量限制", + "getBanLogs": "禁止日誌", + "allClients": "所有客戶", + "addClient": "新增客戶", + "submitDisable": "以停用方式送出 ☑️", + "submitEnable": "以啟用方式送出 ✅", + "use_default": "🏷️ 使用預設值", + "change_email": "⚙️📧 電子郵件", + "change_comment": "⚙️💬 評論", + "ResetAllTraffics": "重設所有流量", + "SortedTrafficUsageReport": "排序過的流量使用報告" + }, + "answers": { + "successfulOperation": "✅ 成功!", + "errorOperation": "❗ 操作錯誤。", + "getInboundsFailed": "❌ 獲取入站資訊失敗。", + "getClientsFailed": "❌ 獲取客戶失敗。", + "canceled": "❌ {{ .Email }}:操作已取消。", + "clientRefreshSuccess": "✅ {{ .Email }}:客戶端重新整理成功。", + "IpRefreshSuccess": "✅ {{ .Email }}:IP 重新整理成功。", + "TGIdRefreshSuccess": "✅ {{ .Email }}:客戶端的 Telegram 使用者重新整理成功。", + "resetTrafficSuccess": "✅ {{ .Email }}:流量已重置成功。", + "setTrafficLimitSuccess": "✅ {{ .Email }}: 流量限制儲存成功。", + "expireResetSuccess": "✅ {{ .Email }}:過期天數已重置成功。", + "resetIpSuccess": "✅ {{ .Email }}:成功儲存 IP 限制數量為 {{ .Count }}。", + "clearIpSuccess": "✅ {{ .Email }}:IP 已成功清除。", + "getIpLog": "✅ {{ .Email }}:獲取 IP 日誌。", + "getUserInfo": "✅ {{ .Email }}:獲取 Telegram 使用者資訊。", + "removedTGUserSuccess": "✅ {{ .Email }}:Telegram 使用者已成功移除。", + "enableSuccess": "✅ {{ .Email }}:已成功啟用。", + "disableSuccess": "✅ {{ .Email }}:已成功禁用。", + "askToAddUserId": "未找到您的配置!\r\n請向管理員詢問,在您的配置中使用您的 Telegram 使用者 ChatID。\r\n\r\n您的使用者 ChatID:{{ .TgUserID }}", + "chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶", + "chooseInbound": "選擇一個入站" + } }, "email": {} } diff --git a/internal/web/web.go b/internal/web/web.go index 63421ff..3a91a6d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -31,6 +31,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" "github.com/mhsanaei/3x-ui/v3/internal/web/websocket" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -118,6 +119,7 @@ type Server struct { xrayService service.XrayService settingService service.SettingService + tgbotService tgbot.Tgbot wsHub *websocket.Hub @@ -218,7 +220,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { // init i18n — still used by backend strings (errors, log messages, // SubPage menu entries) even though the Go template engine is gone. - err = locale.InitLocalizer(i18nFS) + err = locale.InitLocalizer(i18nFS, &s.settingService) if err != nil { return nil, err } @@ -298,6 +300,7 @@ const ( cadenceXrayLogPrune = "@every 10m" // cpu.Percent samples over a full minute (blocking), so a finer cadence just // stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway. + cadenceCheckHash = "@every 2m" cadenceCPUAlarm = "@every 1m" cadenceMemoryAlarm = "@every 1m" ) @@ -352,6 +355,35 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) { // Check monthly reset days at midnight _, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc)) + // Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup. + isTgbotenabled, err := s.settingService.GetTgbotEnabled() + if (err == nil) && isTgbotenabled { + runtime, err := s.settingService.GetTgbotRuntime() + if err != nil { + logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err) + runtime = "@daily" + } else if strings.TrimSpace(runtime) == "" { + logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily") + runtime = "@daily" + } + logger.Infof("Tg notify enabled,run at %s", runtime) + if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil { + logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err) + } + + // check for Telegram bot callback query hash storage reset + _, _ = s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob()) + } + + // CPU/memory monitors publish cpu.high / memory.high events; register them + // whenever a notifier (Telegram) wants them, independent of the bot being on. + if s.cpuAlarmWanted() { + _, _ = s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob()) + } + if s.memoryAlarmWanted() { + _, _ = s.cron.AddJob(cadenceMemoryAlarm, job.NewCheckMemJob()) + } + if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 { _, _ = s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob()) go func() { @@ -361,6 +393,54 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) { } } +// cpuAlarmWanted reports whether any notifier is configured to receive +// cpu.high events with a positive threshold. +func (s *Server) cpuAlarmWanted() bool { + wants := func(events string, threshold int) bool { + if threshold <= 0 { + return false + } + for e := range strings.SplitSeq(events, ",") { + if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) { + return true + } + } + return false + } + if on, _ := s.settingService.GetTgbotEnabled(); on { + events, _ := s.settingService.GetTgEnabledEvents() + cpu, _ := s.settingService.GetTgCpu() + if wants(events, cpu) { + return true + } + } + return false +} + +// memoryAlarmWanted reports whether any notifier is configured to receive +// memory.high events with a positive threshold. +func (s *Server) memoryAlarmWanted() bool { + wants := func(events string, threshold int) bool { + if threshold <= 0 { + return false + } + for e := range strings.SplitSeq(events, ",") { + if strings.TrimSpace(e) == string(eventbus.EventMemoryHigh) { + return true + } + } + return false + } + if on, _ := s.settingService.GetTgbotEnabled(); on { + events, _ := s.settingService.GetTgEnabledEvents() + mem, _ := s.settingService.GetTgMemory() + if wants(events, mem) { + return true + } + } + return false +} + // Start initializes and starts the web server with configured settings, routes, and background jobs. func (s *Server) Start() (err error) { return s.start(true) @@ -492,6 +572,41 @@ func (s *Server) start(restartXray bool) (err error) { // Create event bus before startTask so jobs can use it s.bus = eventbus.New(eventbus.DefaultBufferSize) job.EventBus = s.bus + tgbot.EventBus = s.bus + + // Wire Telegram test function to controller + controller.SetTestTgFunc(func() error { + if !s.tgbotService.IsRunning() { + return fmt.Errorf("telegram bot is not running (check token and chat ID)") + } + if err := s.tgbotService.TestConnection(); err != nil { + return fmt.Errorf("telegram API test failed: %w", err) + } + s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui") + return nil + }) + + controller.SetReloadTgbotFunc(func() { + enabled, err := s.settingService.GetTgbotEnabled() + if err != nil || !enabled { + if s.tgbotService.IsRunning() { + s.tgbotService.Stop() + } + if s.bus != nil { + s.bus.Unsubscribe("tg-notifier") + } + return + } + // Start() stops any previous receiver first, so it is safe whether or not the bot is already running. + tgBot := s.tgbotService.NewTgbot() + if startErr := tgBot.Start(i18nFS); startErr != nil { + logger.Warning("reload Telegram bot failed:", startErr) + return + } + if s.bus != nil { + s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent) + } + }) // Wire xray crash callback BEFORE startTask so it's ready xray.OnCrash = func(err error) { @@ -505,6 +620,13 @@ func (s *Server) start(restartXray bool) (err error) { s.startTask(restartXray, loc) + if isTgbotenabled, err := s.settingService.GetTgbotEnabled(); (err == nil) && isTgbotenabled { + tgBot := s.tgbotService.NewTgbot() + _ = tgBot.Start(i18nFS) + // Subscribe Telegram notifications for event bus + s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent) + } + return nil } @@ -533,6 +655,9 @@ func (s *Server) stop(stopXray bool) error { if stopXray { service.StopTrafficWriter() } + if s.tgbotService.IsRunning() { + s.tgbotService.Stop() + } // Gracefully stop WebSocket hub if s.wsHub != nil { s.wsHub.Stop()