mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760)
The bot answered every action with a new message, so a chat filled with stale menus whose buttons still acted, and long client or inbound lists were paged through dozens of buttons. Each chat now has one live screen that is edited in place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists open through inline search, the add-client flow starts from a draft card, QR codes arrive as one labelled sheet, and repeated notices collapse into one with a counter. A client's /start and /usage show only that client's own links and usage, groups are refused, and the settings page warns when the bot's inline mode is off. Admin topics, the indexed Telegram lookup and every locale, Slovak included, carry over.
This commit is contained in:
@@ -48,6 +48,10 @@ _openapi:
|
||||
title: Test Telegram bot connection by sending a test message to the configured
|
||||
chat.
|
||||
url: '#test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat'
|
||||
- depth: 2
|
||||
title: Report what the configured bot can do. Inline mode is a BotFather switch,
|
||||
so the panel warns when it is off.
|
||||
url: '#report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off'
|
||||
- depth: 2
|
||||
title: Test Discord bot connection by sending a test embed to the configured
|
||||
channel.
|
||||
@@ -90,6 +94,9 @@ _openapi:
|
||||
- content: Test Telegram bot connection by sending a test message to the
|
||||
configured chat.
|
||||
id: test-telegram-bot-connection-by-sending-a-test-message-to-the-configured-chat
|
||||
- content: Report what the configured bot can do. Inline mode is a BotFather
|
||||
switch, so the panel warns when it is off.
|
||||
id: report-what-the-configured-bot-can-do-inline-mode-is-a-botfather-switch-so-the-panel-warns-when-it-is-off
|
||||
- content: Test Discord bot connection by sending a test embed to the configured
|
||||
channel.
|
||||
id: test-discord-bot-connection-by-sending-a-test-embed-to-the-configured-channel
|
||||
@@ -106,7 +113,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/tgBotCapabilities","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4727,6 +4727,35 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TgBotCapabilities": {
|
||||
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
|
||||
"properties": {
|
||||
"groupPrivacy": {
|
||||
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"inlineEnabled": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"running": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"username": {
|
||||
"example": "xui_bot",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"groupPrivacy",
|
||||
"inlineEnabled",
|
||||
"running",
|
||||
"username"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Traffic": {
|
||||
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
||||
"properties": {
|
||||
@@ -13360,6 +13389,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/tgBotCapabilities": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
|
||||
"operationId": "post_panel_api_setting_tgBotCapabilities",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/TgBotCapabilities"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"groupPrivacy": false,
|
||||
"inlineEnabled": true,
|
||||
"running": true,
|
||||
"username": "xui_bot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/testDiscord": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -4727,6 +4727,35 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TgBotCapabilities": {
|
||||
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
|
||||
"properties": {
|
||||
"groupPrivacy": {
|
||||
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"inlineEnabled": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"running": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"username": {
|
||||
"example": "xui_bot",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"groupPrivacy",
|
||||
"inlineEnabled",
|
||||
"running",
|
||||
"username"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Traffic": {
|
||||
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
||||
"properties": {
|
||||
@@ -13360,6 +13389,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/tgBotCapabilities": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.",
|
||||
"operationId": "post_panel_api_setting_tgBotCapabilities",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/TgBotCapabilities"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"groupPrivacy": false,
|
||||
"inlineEnabled": true,
|
||||
"running": true,
|
||||
"username": "xui_bot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/testDiscord": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -1168,6 +1168,12 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"strategy": "random",
|
||||
"updatedAt": 1710000000000
|
||||
},
|
||||
"TgBotCapabilities": {
|
||||
"groupPrivacy": false,
|
||||
"inlineEnabled": true,
|
||||
"running": true,
|
||||
"username": "xui_bot"
|
||||
},
|
||||
"Traffic": {
|
||||
"Down": 2097152,
|
||||
"IsInbound": true,
|
||||
|
||||
@@ -4701,6 +4701,35 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TgBotCapabilities": {
|
||||
"description": "TgBotCapabilities is the browser-safe shape of the bot's own capabilities.",
|
||||
"properties": {
|
||||
"groupPrivacy": {
|
||||
"description": "GroupPrivacy off means the bot reads every group message; when it is on,\na bare /start in a group is invisible to the bot.",
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"inlineEnabled": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"running": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"username": {
|
||||
"example": "xui_bot",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"groupPrivacy",
|
||||
"inlineEnabled",
|
||||
"running",
|
||||
"username"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Traffic": {
|
||||
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
||||
"properties": {
|
||||
|
||||
@@ -1066,6 +1066,13 @@ export interface SubBalancer {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TgBotCapabilities {
|
||||
groupPrivacy: boolean;
|
||||
inlineEnabled: boolean;
|
||||
running: boolean;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface Traffic {
|
||||
Down: number;
|
||||
IsInbound: boolean;
|
||||
|
||||
@@ -1132,6 +1132,14 @@ export const SubBalancerSchema = z.object({
|
||||
});
|
||||
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
|
||||
|
||||
export const TgBotCapabilitiesSchema = z.object({
|
||||
groupPrivacy: z.boolean(),
|
||||
inlineEnabled: z.boolean(),
|
||||
running: z.boolean(),
|
||||
username: z.string(),
|
||||
});
|
||||
export type TgBotCapabilities = z.infer<typeof TgBotCapabilitiesSchema>;
|
||||
|
||||
export const TrafficSchema = z.object({
|
||||
Down: z.number().int(),
|
||||
IsInbound: z.boolean(),
|
||||
|
||||
@@ -2016,6 +2016,13 @@ export const sections: readonly Section[] = [
|
||||
summary: 'Test Telegram bot connection by sending a test message to the configured chat.',
|
||||
response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/tgBotCapabilities',
|
||||
summary:
|
||||
'Report what the configured bot can do. Inline mode is a BotFather switch, so the panel warns when it is off.',
|
||||
responseSchema: 'TgBotCapabilities',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/testDiscord',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Input, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { BellOutlined, SendOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
@@ -21,6 +21,34 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null);
|
||||
const [inlineWarning, setInlineWarning] = useState<string | null>(null);
|
||||
const [privacyWarning, setPrivacyWarning] = useState<string | null>(null);
|
||||
const [capsLoading, setCapsLoading] = useState(false);
|
||||
|
||||
// The bot's lists live in inline mode, which only BotFather can enable, so the
|
||||
// panel checks the live capability and warns instead of leaving dead buttons.
|
||||
async function checkInlineMode() {
|
||||
setCapsLoading(true);
|
||||
try {
|
||||
const res = (await HttpUtil.post('/panel/api/setting/tgBotCapabilities')) as {
|
||||
success?: boolean;
|
||||
obj?: { inlineEnabled?: boolean; groupPrivacy?: boolean; username?: string };
|
||||
};
|
||||
if (res.success && res.obj && !res.obj.inlineEnabled) {
|
||||
setInlineWarning(res.obj.username || '');
|
||||
} else {
|
||||
setInlineWarning(null);
|
||||
}
|
||||
// Privacy mode is the mirror-image trap: with it on, a bare /start typed
|
||||
// in a group never reaches the bot, so the command looks dead.
|
||||
setPrivacyWarning(res.success && !!res.obj?.groupPrivacy ? res.obj?.username || '' : null);
|
||||
} catch {
|
||||
// The bot may simply be stopped; the token field still tells that story.
|
||||
setInlineWarning(null);
|
||||
} finally {
|
||||
setCapsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestTgBot() {
|
||||
setTestLoading(true);
|
||||
@@ -134,12 +162,43 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
{inlineWarning !== null && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<WarningOutlined aria-hidden />}
|
||||
style={{ marginTop: 16 }}
|
||||
title={t('pages.settings.tgInlineMissing')}
|
||||
description={t('pages.settings.tgInlineMissingDesc')}
|
||||
action={
|
||||
<Button size="small" loading={capsLoading} onClick={checkInlineMode}>
|
||||
{t('pages.settings.tgInlineRecheck')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{privacyWarning !== null && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<WarningOutlined aria-hidden />}
|
||||
style={{ marginTop: 16 }}
|
||||
title={t('pages.settings.tgPrivacyOn')}
|
||||
description={t('pages.settings.tgPrivacyOnDesc')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined aria-hidden />}
|
||||
loading={testLoading}
|
||||
onClick={handleTestTgBot}
|
||||
loading={testLoading || capsLoading}
|
||||
onClick={() => {
|
||||
setTestResult(null);
|
||||
void handleTestTgBot();
|
||||
void checkInlineMode();
|
||||
}}
|
||||
>
|
||||
{t('pages.settings.testTgBot')}
|
||||
</Button>
|
||||
|
||||
@@ -80,6 +80,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
|
||||
g.POST("/testSmtp", a.testSmtp)
|
||||
g.POST("/testTgBot", a.testTgBot)
|
||||
g.POST("/tgBotCapabilities", a.tgBotCapabilities)
|
||||
g.POST("/testDiscord", a.testDiscord)
|
||||
}
|
||||
|
||||
@@ -348,6 +349,42 @@ func (a *SettingController) testTgBot(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started"))
|
||||
}
|
||||
|
||||
// tgBotCapabilities reports what the bot can do. Inline mode is a BotFather
|
||||
// switch the API can read but never set, so the panel warns when lists can't open.
|
||||
func (a *SettingController) tgBotCapabilities(c *gin.Context) {
|
||||
enabled, err := a.settingService.GetTgbotEnabled()
|
||||
if err != nil || !enabled {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled"))
|
||||
return
|
||||
}
|
||||
if tgCapsFunc == nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started"))
|
||||
return
|
||||
}
|
||||
caps, err := tgCapsFunc()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, caps, nil)
|
||||
}
|
||||
|
||||
// TgBotCapabilities is the browser-safe shape of the bot's own capabilities.
|
||||
type TgBotCapabilities struct {
|
||||
InlineEnabled bool `json:"inlineEnabled" example:"true"`
|
||||
// GroupPrivacy off means the bot reads every group message; when it is on,
|
||||
// a bare /start in a group is invisible to the bot.
|
||||
GroupPrivacy bool `json:"groupPrivacy" example:"false"`
|
||||
Username string `json:"username" example:"xui_bot"`
|
||||
Running bool `json:"running" example:"true"`
|
||||
}
|
||||
|
||||
// tgCapsFunc is wired from the web layer; importing tgbot here is circular.
|
||||
var tgCapsFunc func() (TgBotCapabilities, error)
|
||||
|
||||
// SetTgBotCapsFunc registers the capability probe.
|
||||
func SetTgBotCapsFunc(fn func() (TgBotCapabilities, error)) { tgCapsFunc = fn }
|
||||
|
||||
// testTgFunc is set from web layer to test Telegram sending without circular imports.
|
||||
var testTgFunc func() error
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package tgbot
|
||||
|
||||
// helpers.go holds the small shared primitives the screen layer needs, so the
|
||||
// router never grows a second copy of them.
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// xrayProcessOrNil returns the running Xray process, or nil; a panel whose Xray
|
||||
// is down is a normal state the screens must render, not an error.
|
||||
func xrayProcessOrNil() *xray.Process {
|
||||
process := service.XrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return nil
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
// panelVersion is the version string the menu shows.
|
||||
func panelVersion() string { return config.GetPanelVersion() }
|
||||
@@ -0,0 +1,131 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// I18nBot returns "" for a missing key, so a screen renders blank buttons without failing; that is how
|
||||
// /start once answered with no menu. Every key a Go source asks for must exist.
|
||||
|
||||
var (
|
||||
i18nBotKeyPattern = regexp.MustCompile(`I18nBot\(\s*"([^"]+)"`)
|
||||
// Button labels go through btn(), which also calls I18nBot internally, so a
|
||||
// broken button key is invisible to a scan of I18nBot calls alone.
|
||||
btnKeyPattern = regexp.MustCompile(`\.btn(?:2)?\(\s*"([^"]+)"`)
|
||||
// The category screen asks for "tgbot.messages.category_" + name.
|
||||
categoryKeyPattern = regexp.MustCompile(`tgbot\.messages\.category_"`)
|
||||
)
|
||||
|
||||
func TestEveryBotKeyExistsInTheTranslations(t *testing.T) {
|
||||
root := repoRootForTest(t)
|
||||
keys := map[string]string{} // key -> first file that asks for it
|
||||
err := filepath.Walk(filepath.Join(root, "internal"), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patterns := []*regexp.Regexp{i18nBotKeyPattern, btnKeyPattern}
|
||||
for _, pattern := range patterns {
|
||||
for _, m := range pattern.FindAllStringSubmatch(string(data), -1) {
|
||||
key := m[1]
|
||||
// Keys are also built at run time ("tgbot.screens." + kind); a literal ending in a separator is such
|
||||
// a prefix, and the set it expands to is checked explicitly.
|
||||
if strings.ContainsAny(key, "%$") || strings.HasSuffix(key, ".") || strings.HasSuffix(key, "_") {
|
||||
continue
|
||||
}
|
||||
if _, seen := keys[key]; !seen {
|
||||
keys[key] = strings.TrimPrefix(path, root+"/")
|
||||
}
|
||||
}
|
||||
}
|
||||
if categoryKeyPattern.MatchString(string(data)) {
|
||||
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
|
||||
keys["tgbot.messages.category_"+category] = strings.TrimPrefix(path, root+"/")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("scan sources: %v", err)
|
||||
}
|
||||
|
||||
// Keys assembled from a prefix at run time; their full set is checked so the
|
||||
// dynamic path cannot hide a missing entry.
|
||||
for _, kind := range screenKinds {
|
||||
keys["tgbot.screens."+kind] = "screens_art.go (screenKinds)"
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(root, "internal/web/translation/*.json"))
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no translation files found: %v", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
var bundle map[string]any
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
var missing []string
|
||||
for key, source := range keys {
|
||||
if !nestedKeyPresent(bundle, key) {
|
||||
missing = append(missing, fmt.Sprintf("%s (%s)", key, source))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("%s is missing %d key(s) the bot asks for:\n %s",
|
||||
filepath.Base(file), len(missing), strings.Join(missing, "\n "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nestedKeyPresent walks a dotted key through the nested translation objects.
|
||||
func nestedKeyPresent(bundle map[string]any, key string) bool {
|
||||
var current any = bundle
|
||||
for _, part := range strings.Split(key, ".") {
|
||||
obj, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current, ok = obj[part]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// repoRootForTest walks up from the test's working directory to the module root.
|
||||
func repoRootForTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("module root not found")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// Panel notifications (outage, failed login, backup) are ONE live message per
|
||||
// (chat, kind): repeats edit it in place instead of burying the chat.
|
||||
|
||||
type noticeKey struct {
|
||||
chatID int64
|
||||
kind string
|
||||
}
|
||||
|
||||
type noticeState struct {
|
||||
msgID int
|
||||
body string
|
||||
count int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type noticeStore struct {
|
||||
mu sync.Mutex
|
||||
notices map[noticeKey]noticeState
|
||||
}
|
||||
|
||||
var notices = ¬iceStore{notices: map[noticeKey]noticeState{}}
|
||||
|
||||
func (s *noticeStore) get(key noticeKey) (noticeState, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st, ok := s.notices[key]
|
||||
return st, ok
|
||||
}
|
||||
|
||||
func (s *noticeStore) put(key noticeKey, st noticeState) {
|
||||
s.mu.Lock()
|
||||
s.notices[key] = st
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *noticeStore) reset() {
|
||||
s.mu.Lock()
|
||||
s.notices = map[noticeKey]noticeState{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// noticeRepeatWindow is how long a repeated event of the same kind keeps
|
||||
// updating the live card instead of starting a new one.
|
||||
const noticeRepeatWindow = 30 * time.Minute
|
||||
|
||||
// liveNotice sends or updates the chat's card for one kind of event; a repeat
|
||||
// inside the window edits it and counts, so a storm reads as one numbered line.
|
||||
func (t *Tgbot) liveNotice(chatID int64, kind, body string) {
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
key := noticeKey{chatID: chatID, kind: kind}
|
||||
st, ok := notices.get(key)
|
||||
|
||||
if ok && time.Since(st.at) < noticeRepeatWindow && st.msgID != 0 {
|
||||
st.count++
|
||||
st.at = time.Now()
|
||||
st.body = body
|
||||
text := t.noticeText(body, st.count)
|
||||
if t.editNotice(chatID, st.msgID, text) {
|
||||
notices.put(key, st)
|
||||
return
|
||||
}
|
||||
// The card is gone or too old to edit: fall through and post a fresh one.
|
||||
}
|
||||
|
||||
st = noticeState{body: body, count: 1, at: time.Now()}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
sc, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: t.noticeText(body, 1), ParseMode: "HTML", ReplyMarkup: t.hideButton(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send a notice:", err)
|
||||
return
|
||||
}
|
||||
st.msgID = sc.MessageID
|
||||
notices.put(key, st)
|
||||
}
|
||||
|
||||
// noticeText renders the event plus its repeat counter.
|
||||
func (t *Tgbot) noticeText(body string, count int) string {
|
||||
text := body
|
||||
if count > 1 {
|
||||
text += t.I18nBot("tgbot.notices.repeat", "Count=="+itoa(count), "Time=="+time.Now().Format("15:04:05"))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// editNotice rewrites a live card. false means it could not be edited and the
|
||||
// caller must post a new one.
|
||||
func (t *Tgbot) editNotice(chatID int64, msgID int, text string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_, err := liveBot().EditMessageText(ctx, &telego.EditMessageTextParams{
|
||||
ChatID: tu.ID(chatID), MessageID: msgID, Text: text, ParseMode: "HTML",
|
||||
ReplyMarkup: t.hideButton(),
|
||||
})
|
||||
if err == nil || isTelegramNotModifiedError(err) {
|
||||
return true
|
||||
}
|
||||
logger.Debug("Notice not editable, posting a new one:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// dropNoticeFor forgets the card a hidden message belonged to, so the next
|
||||
// event of that kind starts a fresh card.
|
||||
func (t *Tgbot) dropNoticeFor(chatID int64, msgID int) {
|
||||
notices.mu.Lock()
|
||||
defer notices.mu.Unlock()
|
||||
for key, st := range notices.notices {
|
||||
if key.chatID == chatID && st.msgID == msgID {
|
||||
delete(notices.notices, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendDocumentWithCaption uploads a file with its summary as caption: a document
|
||||
// cannot hold a screen, so one message does the work of two.
|
||||
func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption string) bool {
|
||||
if liveBot() == nil || data == nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
_, err := liveBot().SendDocument(ctx, &telego.SendDocumentParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID),
|
||||
Document: tu.FileFromBytes(data, name),
|
||||
Caption: caption,
|
||||
ParseMode: "HTML",
|
||||
ReplyMarkup: t.hideButton(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to upload a file with a caption:", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// trimCaption cuts a summary down to what a caption can carry, on a line break
|
||||
// so a sentence is never left half-written.
|
||||
func trimCaption(text string, limit int) string {
|
||||
if limit <= 0 || len([]rune(text)) <= limit {
|
||||
return text
|
||||
}
|
||||
runes := []rune(text)
|
||||
cut := string(runes[:limit])
|
||||
if idx := strings.LastIndex(cut, "\n"); idx > 0 {
|
||||
cut = cut[:idx]
|
||||
}
|
||||
return strings.TrimRight(cut, "\r\n")
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
)
|
||||
|
||||
// noticeServer records sendMessage/sendDocument/editMessageText with the visible body, so a test can
|
||||
// assert how many messages an event burst produced.
|
||||
func noticeServer(t *testing.T) (*httptest.Server, func() []apiCall) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var calls []apiCall
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
sent := extractSentBody(t, r)
|
||||
mu.Lock()
|
||||
calls = append(calls, apiCall{Method: method, Payload: map[string]any{
|
||||
"text": sent.Text, "chat_id": float64(sent.ChatID),
|
||||
}})
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch method {
|
||||
case "sendMessage", "sendDocument", "sendPhoto":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
||||
"message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"},
|
||||
}})
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
||||
"message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"},
|
||||
}})
|
||||
}
|
||||
}))
|
||||
return srv, func() []apiCall {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]apiCall(nil), calls...)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test: the same event twice must edit its live card, not post a
|
||||
// second message — a flapping outbound used to bury the chat.
|
||||
func TestRepeatedEventEditsItsLiveCard(t *testing.T) {
|
||||
srv, calls := noticeServer(t)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
setTestAdmins(t, 1)
|
||||
notices.reset()
|
||||
|
||||
tb := initReportDB(t)
|
||||
event := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "nl-node"}
|
||||
|
||||
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
|
||||
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
|
||||
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
|
||||
|
||||
got := calls()
|
||||
if n := countMethod(got, "sendMessage"); n != 1 {
|
||||
t.Errorf("sendMessage = %d, want 1: repeats must edit, not post (calls: %v)", n, methods(got))
|
||||
}
|
||||
if n := countMethod(got, "editMessageText"); n != 2 {
|
||||
t.Errorf("editMessageText = %d, want 2: each repeat redraws the card", n)
|
||||
}
|
||||
_ = event
|
||||
}
|
||||
|
||||
// A different kind of event gets its own card: they tell unrelated stories.
|
||||
func TestDifferentEventKindsKeepSeparateCards(t *testing.T) {
|
||||
srv, calls := noticeServer(t)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
setTestAdmins(t, 1)
|
||||
notices.reset()
|
||||
|
||||
tb := initReportDB(t)
|
||||
tb.liveNotice(1, "outbound_down:a", "A is DOWN")
|
||||
tb.liveNotice(1, "xray_crash:x", "Xray crashed")
|
||||
|
||||
if n := countMethod(calls(), "sendMessage"); n != 2 {
|
||||
t.Errorf("sendMessage = %d, want 2: unrelated events need their own cards", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The backup must arrive as one message: the file carries the summary.
|
||||
func TestBackupArrivesAsOneMessageWithACaption(t *testing.T) {
|
||||
srv, calls := noticeServer(t)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
notices.reset()
|
||||
|
||||
tb := initReportDB(t)
|
||||
tb.sendDocumentWithCaption(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time")
|
||||
|
||||
got := calls()
|
||||
sends := 0
|
||||
for _, c := range got {
|
||||
if c.Method == "sendMessage" || c.Method == "sendDocument" {
|
||||
sends++
|
||||
}
|
||||
}
|
||||
if sends != 1 {
|
||||
t.Fatalf("messages = %d, want 1 (calls: %v)", sends, methods(got))
|
||||
}
|
||||
if countMethod(got, "sendDocument") != 1 {
|
||||
t.Error("the backup did not travel as a document")
|
||||
}
|
||||
}
|
||||
|
||||
// A caption longer than Telegram's limit is cut on a line break, never mid-line.
|
||||
func TestTrimCaptionCutsOnALineBreak(t *testing.T) {
|
||||
body := strings.Repeat("line of a long report\n", 200)
|
||||
got := trimCaption(body, botCaptionLimit)
|
||||
if len([]rune(got)) > botCaptionLimit {
|
||||
t.Fatalf("caption is %d runes, want at most %d", len([]rune(got)), botCaptionLimit)
|
||||
}
|
||||
if strings.HasSuffix(got, "\n") || strings.HasSuffix(got, "\r") {
|
||||
t.Errorf("caption ends on a dangling line break: %q", got[max(0, len(got)-20):])
|
||||
}
|
||||
}
|
||||
|
||||
// Hiding a notice forgets its card, so the next event does not edit a message
|
||||
// the user just deleted.
|
||||
func TestHiddenNoticeIsForgotten(t *testing.T) {
|
||||
srv, _ := noticeServer(t)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
setTestAdmins(t, 1)
|
||||
notices.reset()
|
||||
|
||||
tb := initReportDB(t)
|
||||
tb.liveNotice(1, "outbound_down:a", "A is DOWN")
|
||||
key := noticeKey{chatID: 1, kind: "outbound_down:a"}
|
||||
if _, ok := notices.get(key); !ok {
|
||||
t.Fatal("the live card was not remembered")
|
||||
}
|
||||
tb.dropNoticeFor(1, 77)
|
||||
if _, ok := notices.get(key); ok {
|
||||
t.Error("a hidden notice is still remembered; the next event would edit a deleted message")
|
||||
}
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
var _ = io.Discard
|
||||
@@ -0,0 +1,477 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// botCaptionLimit is Telegram's caption ceiling; a longer caption is a hard API
|
||||
// error, so a screen pages its body instead of risking a lost render.
|
||||
const botCaptionLimit = 1024
|
||||
|
||||
// screen is the bot's only chat surface: one photo message whose caption and
|
||||
// keyboard are edited on every action, so a chat never grows a second menu.
|
||||
type screen struct {
|
||||
msgID int
|
||||
kind string
|
||||
// root marks a screen that IS the viewer's top level: it must not offer a
|
||||
// back button, because there is nothing above it to go back to.
|
||||
root bool
|
||||
// qrPicture is the sheet of QR codes a "qr" screen shows instead of the
|
||||
// shared avatar: a code belongs to one client, so it is rendered per screen.
|
||||
qrPicture *telego.InputFile
|
||||
// qrSource is the subscription URL the sheet was built from.
|
||||
qrSource string
|
||||
// attachment is a picture built for this one screen (a QR). It is uploaded
|
||||
// on send and remembered by content, so an edit reuses the same picture.
|
||||
attachment *telego.InputFile
|
||||
photo string // file_id to reuse for an edit; empty means upload or text
|
||||
hasPhoto bool // whether the tracked message carries a picture
|
||||
header string
|
||||
body string
|
||||
pages []string
|
||||
page int
|
||||
rows [][]telego.InlineKeyboardButton
|
||||
markup *telego.InlineKeyboardMarkup
|
||||
text string
|
||||
pageMax int
|
||||
}
|
||||
|
||||
// screenStore keeps the live screen per chat. The msgID inside it belongs to
|
||||
// that chat, so a stale keyboard cannot address another chat's screen.
|
||||
type screenStore struct {
|
||||
mu sync.Mutex
|
||||
chats map[int64]*screen
|
||||
}
|
||||
|
||||
var screens = &screenStore{chats: map[int64]*screen{}}
|
||||
|
||||
func (s *screenStore) get(chatID int64) (*screen, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sc, ok := s.chats[chatID]
|
||||
return sc, ok
|
||||
}
|
||||
|
||||
func (s *screenStore) put(chatID int64, sc *screen) {
|
||||
s.mu.Lock()
|
||||
s.chats[chatID] = sc
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *screenStore) clear(chatID int64) {
|
||||
s.mu.Lock()
|
||||
delete(s.chats, chatID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *screenStore) reset() {
|
||||
s.mu.Lock()
|
||||
s.chats = map[int64]*screen{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// newScreen assembles a screen: breadcrumb header, body paged into
|
||||
// caption-sized chunks, and the caller's keyboard rows.
|
||||
|
||||
// rootScreen builds the viewer's own top level, so the way-out invariant does
|
||||
// not apply: a back button there would lead to the same screen.
|
||||
func (t *Tgbot) rootScreen(kind, body string, rows ...[]telego.InlineKeyboardButton) *screen {
|
||||
sc := t.newScreen(kind, body, append(rows, t.backRow())...)
|
||||
sc.root = true
|
||||
sc.rows = stripWayOut(sc.rows)
|
||||
if sc.markup != nil {
|
||||
sc.markup = tu.InlineKeyboard(sc.rows...)
|
||||
}
|
||||
return sc
|
||||
}
|
||||
|
||||
// stripWayOut removes the home row. Used only when building a root screen, where
|
||||
// a way out would be a link to itself.
|
||||
func stripWayOut(rows [][]telego.InlineKeyboardButton) [][]telego.InlineKeyboardButton {
|
||||
out := make([][]telego.InlineKeyboardButton, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
kept := make([]telego.InlineKeyboardButton, 0, len(row))
|
||||
for _, btn := range row {
|
||||
if btn.CallbackData != cbHome {
|
||||
kept = append(kept, btn)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
out = append(out, kept)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *Tgbot) newScreen(kind, body string, rows ...[]telego.InlineKeyboardButton) *screen {
|
||||
// One header builder for every screen, so none can open as a dead end.
|
||||
// Terminal callbacks beyond "home" are registered by the caller.
|
||||
rows = t.ensureWayOut(rows)
|
||||
header := ""
|
||||
if crumb := t.breadcrumb(kind); crumb != "" {
|
||||
header = "<b>" + crumb + "</b>\r\n"
|
||||
}
|
||||
// Bodies arrive with either line ending; paging splits on CRLF, so a body
|
||||
// built with bare LF would silently stay one over-long page.
|
||||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||||
body = strings.ReplaceAll(body, "\n", "\r\n")
|
||||
pages := pageMessage(body, botCaptionLimit-len(header))
|
||||
if len(pages) == 0 {
|
||||
pages = []string{""}
|
||||
}
|
||||
return &screen{kind: kind, header: header, body: body, pages: pages, rows: rows, pageMax: len(pages)}
|
||||
}
|
||||
|
||||
// renderScreen is the single entry point for drawing a screen: EDIT first, and
|
||||
// replace (send then delete) only when the edit is genuinely impossible.
|
||||
func (t *Tgbot) renderScreen(chatID int64, sc *screen) {
|
||||
if sc == nil || liveBot() == nil {
|
||||
return
|
||||
}
|
||||
// A QR screen carries its own picture: the codes being shown are what the
|
||||
// screen was opened for, and a file beside it is just clutter.
|
||||
if sc.qrPicture != nil {
|
||||
sc.hasPhoto, sc.attachment = true, sc.qrPicture
|
||||
} else {
|
||||
t.attachArt(sc)
|
||||
}
|
||||
sc.text = sc.header + sc.pages[min(sc.page, len(sc.pages)-1)]
|
||||
rows := sc.rows
|
||||
if row := t.pagerRow(sc); row != nil {
|
||||
rows = append(append([][]telego.InlineKeyboardButton{}, sc.rows...), row)
|
||||
}
|
||||
sc.markup = tu.InlineKeyboard(rows...)
|
||||
|
||||
if prev, ok := t.screens().get(chatID); ok && prev.msgID != 0 {
|
||||
if t.editScreen(chatID, prev, sc) {
|
||||
sc.msgID = prev.msgID
|
||||
t.screens().put(chatID, sc)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.replaceScreen(chatID, sc)
|
||||
}
|
||||
|
||||
// attachArt pins the screen's picture (avatar, else the always-available black
|
||||
// tile), so a screen is always a photo message and edits can go through media.
|
||||
func (t *Tgbot) attachArt(sc *screen) {
|
||||
// A stale cache is refreshed off the render path, so a picture uploaded to
|
||||
// @BotFather shows up without a panel restart and no screen waits on it.
|
||||
t.ensureScreenArt()
|
||||
sc.hasPhoto = true
|
||||
if id, ok := artFileID(sc.kind); ok {
|
||||
sc.photo = id
|
||||
return
|
||||
}
|
||||
sc.photo = ""
|
||||
}
|
||||
|
||||
// screens keeps one call site for the store, so a test can swap it.
|
||||
func (t *Tgbot) screens() *screenStore { return screens }
|
||||
|
||||
// editScreen edits the tracked message into the new screen. false means the
|
||||
// caller must replace it: the message is gone, too old, or of another type.
|
||||
func (t *Tgbot) editScreen(chatID int64, prev *screen, next *screen) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case next.hasPhoto && next.photo != "":
|
||||
_, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageID: prev.msgID,
|
||||
Media: &telego.InputMediaPhoto{
|
||||
Type: "photo", Media: tu.FileFromID(next.photo), Caption: next.text, ParseMode: "HTML",
|
||||
},
|
||||
ReplyMarkup: next.markup,
|
||||
})
|
||||
case next.attachment != nil:
|
||||
_, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageID: prev.msgID,
|
||||
Media: &telego.InputMediaPhoto{
|
||||
Type: "photo", Media: *next.attachment, Caption: next.text, ParseMode: "HTML",
|
||||
},
|
||||
ReplyMarkup: next.markup,
|
||||
})
|
||||
case next.hasPhoto:
|
||||
// No file_id yet (the avatar was never read): upload the fallback tile.
|
||||
upload, ok := artUpload(next.kind)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, err = liveBot().EditMessageMedia(ctx, &telego.EditMessageMediaParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageID: prev.msgID,
|
||||
Media: &telego.InputMediaPhoto{
|
||||
Type: "photo", Media: upload, Caption: next.text, ParseMode: "HTML",
|
||||
},
|
||||
ReplyMarkup: next.markup,
|
||||
})
|
||||
case prev.hasPhoto:
|
||||
// A photo message cannot become a text one: drop the picture and let
|
||||
// its caption carry the screen.
|
||||
_, err = liveBot().EditMessageCaption(ctx, &telego.EditMessageCaptionParams{
|
||||
ChatID: tu.ID(chatID), MessageID: prev.msgID,
|
||||
Caption: next.text, ParseMode: "HTML", ReplyMarkup: next.markup,
|
||||
})
|
||||
default:
|
||||
_, err = liveBot().EditMessageText(ctx, &telego.EditMessageTextParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageID: prev.msgID,
|
||||
Text: next.text,
|
||||
ParseMode: "HTML",
|
||||
ReplyMarkup: next.markup,
|
||||
})
|
||||
}
|
||||
if err == nil || isTelegramNotModifiedError(err) {
|
||||
return true
|
||||
}
|
||||
logger.Debug("Screen not editable, replacing:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// replaceScreen sends the new screen BEFORE deleting the old one: with a slow
|
||||
// send, delete-first leaves the chat empty and reads as broken.
|
||||
func (t *Tgbot) replaceScreen(chatID int64, sc *screen) int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prev, hadPrev := t.screens().get(chatID)
|
||||
var sent *telego.Message
|
||||
var err error
|
||||
switch {
|
||||
case sc.hasPhoto && sc.photo != "":
|
||||
sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: tu.FileFromID(sc.photo),
|
||||
Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
|
||||
})
|
||||
case sc.attachment != nil:
|
||||
sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: *sc.attachment,
|
||||
Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
|
||||
})
|
||||
case sc.hasPhoto:
|
||||
upload, ok := artUpload(sc.kind)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
sent, err = liveBot().SendPhoto(ctx, &telego.SendPhotoParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Photo: upload,
|
||||
Caption: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
|
||||
})
|
||||
default:
|
||||
sent, err = liveBot().SendMessage(ctx, &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: sc.text, ParseMode: "HTML", ReplyMarkup: sc.markup,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send a screen:", err)
|
||||
return 0
|
||||
}
|
||||
if hadPrev && prev.msgID != 0 {
|
||||
t.deleteOwnMessage(chatID, prev.msgID)
|
||||
}
|
||||
sc.msgID = sent.MessageID
|
||||
// Telegram hands back the file_id of the uploaded tile: remembering it keeps
|
||||
// every later edit a cheap media edit instead of another upload.
|
||||
if sc.hasPhoto && sc.photo == "" && len(sent.Photo) > 0 {
|
||||
sc.photo = sent.Photo[len(sent.Photo)-1].FileID
|
||||
cacheArtFileID(sc.kind, sc.photo)
|
||||
}
|
||||
t.screens().put(chatID, sc)
|
||||
return sent.MessageID
|
||||
}
|
||||
|
||||
// ensureBackRow guarantees every screen can be left (a screen with only its own
|
||||
// actions traps the user) and checks by callback, not label.
|
||||
|
||||
// wayOutNotes are callbacks that already return to a root menu, so a screen
|
||||
// carrying one must not gain a second, confusing back row.
|
||||
var wayOutNotes = []string{cbHome}
|
||||
|
||||
// terminalCallbacks extends wayOutNotes: an engine may register the callbacks
|
||||
// its own root menu answers to. Guarded because tests and the panel install in.
|
||||
var (
|
||||
terminalCallbacksMu sync.RWMutex
|
||||
terminalCallbacks []string
|
||||
)
|
||||
|
||||
func registerTerminalCallbacks(data ...string) {
|
||||
terminalCallbacksMu.Lock()
|
||||
terminalCallbacks = append(terminalCallbacks, data...)
|
||||
terminalCallbacksMu.Unlock()
|
||||
}
|
||||
|
||||
// hasWayOut reports whether any button on the screen is a route back to a root
|
||||
// menu, so that a screen offering one is not a dead end.
|
||||
func hasWayOut(rows [][]telego.InlineKeyboardButton) bool {
|
||||
terminalCallbacksMu.RLock()
|
||||
known := append(append([]string{}, wayOutNotes...), terminalCallbacks...)
|
||||
terminalCallbacksMu.RUnlock()
|
||||
for _, row := range rows {
|
||||
for _, btn := range row {
|
||||
for _, want := range known {
|
||||
if btn.CallbackData == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureWayOut appends the home row to a screen that offers no exit. This is
|
||||
// the one place screens are built, so no new screen can forget it.
|
||||
func (t *Tgbot) ensureWayOut(rows [][]telego.InlineKeyboardButton) [][]telego.InlineKeyboardButton {
|
||||
if hasWayOut(rows) {
|
||||
return rows
|
||||
}
|
||||
return append(append([][]telego.InlineKeyboardButton{}, rows...), t.backRow())
|
||||
}
|
||||
|
||||
// breadcrumb names the screen's place in the tree, so a user who was away can
|
||||
// see where a stale keyboard left them.
|
||||
func (t *Tgbot) breadcrumb(kind string) string {
|
||||
key := "tgbot.screens." + kind
|
||||
crumb := t.I18nBot(key)
|
||||
if crumb == "" || crumb == key {
|
||||
return ""
|
||||
}
|
||||
return t.I18nBot("tgbot.screens.panel") + " › " + crumb
|
||||
}
|
||||
|
||||
// pagerRow adds the page controls when the body did not fit one caption; the
|
||||
// arrows are callback data, never a bare icon-only target.
|
||||
func (t *Tgbot) pagerRow(sc *screen) []telego.InlineKeyboardButton {
|
||||
if sc.pageMax <= 1 {
|
||||
return nil
|
||||
}
|
||||
prev := tu.InlineKeyboardButton("‹").WithCallbackData("pg:prev")
|
||||
if sc.page <= 0 {
|
||||
prev = tu.InlineKeyboardButton("·").WithCallbackData("pg:none")
|
||||
}
|
||||
next := tu.InlineKeyboardButton("›").WithCallbackData("pg:next")
|
||||
if sc.page >= sc.pageMax-1 {
|
||||
next = tu.InlineKeyboardButton("·").WithCallbackData("pg:none")
|
||||
}
|
||||
return tu.InlineKeyboardRow(prev, tu.InlineKeyboardButton(t.pageLabel(sc.page, sc.pageMax)).WithCallbackData("pg:none"), next)
|
||||
}
|
||||
|
||||
// pageLabel renders "Стр. 2 из 5" through the locale, never a raw "2/5".
|
||||
func (t *Tgbot) pageLabel(page, pages int) string {
|
||||
return t.I18nBot("tgbot.buttons.page", "Page=="+itoa(page+1), "Pages=="+itoa(pages))
|
||||
}
|
||||
|
||||
// showPage re-renders the stored screen at another page of its body.
|
||||
func (t *Tgbot) showPage(chatID int64, page int) {
|
||||
sc, ok := t.screens().get(chatID)
|
||||
if !ok || sc.pageMax <= 1 || page < 0 || page >= sc.pageMax {
|
||||
return
|
||||
}
|
||||
sc.page = page
|
||||
sc.pages = pageMessage(sc.body, botCaptionLimit-len(sc.header))
|
||||
t.renderScreen(chatID, sc)
|
||||
}
|
||||
|
||||
// deleteScreenMessage is best effort: the user may have removed the screen, or
|
||||
// Telegram may refuse past the 48-hour window. Neither is worth operator noise.
|
||||
func (t *Tgbot) deleteScreenMessage(chatID int64, msgID int) { t.deleteOwnMessage(chatID, msgID) }
|
||||
|
||||
// deleteIncoming removes a message the user sent: the wizard's text replies and
|
||||
// the pilot taps must not pile up in the chat.
|
||||
func (t *Tgbot) deleteIncoming(message *telego.Message) {
|
||||
if message == nil || liveBot() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := liveBot().DeleteMessage(ctx, &telego.DeleteMessageParams{ChatID: tu.ID(message.Chat.ID), MessageID: message.MessageID}); err != nil {
|
||||
logger.Debug("Cannot delete a user message:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteIncomingID removes one of the user's own messages by id, for flows that
|
||||
// hold ids rather than message structs (the broadcast's composing messages).
|
||||
func (t *Tgbot) deleteIncomingID(chatID int64, messageID int) {
|
||||
if messageID == 0 {
|
||||
return
|
||||
}
|
||||
t.deleteOwnMessage(chatID, messageID)
|
||||
}
|
||||
|
||||
// deleteOwnMessage removes one of our messages without surfacing a failure: the
|
||||
// user may have deleted it, and Telegram refuses past 48 hours.
|
||||
func (t *Tgbot) deleteOwnMessage(chatID int64, msgID int) {
|
||||
if msgID == 0 || liveBot() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := liveBot().DeleteMessage(ctx, &telego.DeleteMessageParams{ChatID: tu.ID(chatID), MessageID: msgID}); err != nil {
|
||||
logger.Debug("Cannot delete a bot message:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// hideButton is the keyboard of a non-screen message: one tap clears it.
|
||||
func (t *Tgbot) hideButton() *telego.InlineKeyboardMarkup {
|
||||
return hideButtonMarkup()
|
||||
}
|
||||
|
||||
// hideButtonMarkup is the hide keyboard for callers with no Tgbot (e.g. a
|
||||
// broadcast copy); the label falls back to English when the key cannot resolve.
|
||||
func hideButtonMarkup() *telego.InlineKeyboardMarkup {
|
||||
label := (&Tgbot{}).I18nBot("tgbot.buttons.hide")
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = "🙈 Hide"
|
||||
}
|
||||
return tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(label).WithCallbackData("hide"),
|
||||
))
|
||||
}
|
||||
|
||||
// answerSilent acknowledges a callback without a toast, for taps whose only
|
||||
// effect is visible in the message itself.
|
||||
func (t *Tgbot) answerSilent(callbackID string) {
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := liveBot().AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
|
||||
logger.Debug("Answer callback query failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := v < 0
|
||||
if neg {
|
||||
v = -v
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// I18nBotStatic is gone; labels are built on the Tgbot receiver.
|
||||
@@ -0,0 +1,136 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// The QR screen lays every code (subscription, JSON, up to five app links) on
|
||||
// ONE picture instead of one document each — up to seven files before.
|
||||
|
||||
// subscriptionQRCode is one code on the sheet, in the order it is drawn.
|
||||
type subscriptionQRCode struct {
|
||||
content string
|
||||
// kind selects the legend line: "sub", "json" or "link".
|
||||
kind string
|
||||
// app numbers an app link in the legend.
|
||||
app int
|
||||
}
|
||||
|
||||
// subscriptionQRCodes collects every code for a client in the order Telegram
|
||||
// used to deliver them: subscription, JSON subscription, then the app links.
|
||||
func (t *Tgbot) subscriptionQRCodes(email, subURL string) []subscriptionQRCode {
|
||||
if subURL == "" {
|
||||
return nil
|
||||
}
|
||||
codes := []subscriptionQRCode{{content: subURL, kind: "sub"}}
|
||||
if _, subJSON, err := t.buildSubscriptionURLs(email); err == nil && subJSON != "" {
|
||||
codes = append(codes, subscriptionQRCode{content: subJSON, kind: "json"})
|
||||
}
|
||||
if links, err := t.clientSubLinks(email, subURL); err == nil {
|
||||
for i, link := range links {
|
||||
if i >= qrSheetMaxLinks {
|
||||
break
|
||||
}
|
||||
codes = append(codes, subscriptionQRCode{content: link, kind: "link", app: i + 1})
|
||||
}
|
||||
}
|
||||
if len(codes) > qrSheetMaxCodes {
|
||||
codes = codes[:qrSheetMaxCodes]
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
const (
|
||||
qrTileSize = 320
|
||||
qrSheetPad = 24
|
||||
qrSheetMaxCols = 3
|
||||
// The sheet stays inside Telegram's photo size limits while covering what
|
||||
// the original sent as separate documents.
|
||||
qrSheetMaxCodes = 6
|
||||
qrSheetMaxLinks = 4
|
||||
)
|
||||
|
||||
// qrCodeLegend numbers the codes in the picture's own order, so a code can be
|
||||
// told apart without reading it: the tiles carry no text, they are scanned.
|
||||
func (t *Tgbot) qrCodeLegend(codes []subscriptionQRCode) string {
|
||||
if len(codes) == 0 {
|
||||
return ""
|
||||
}
|
||||
var body bytes.Buffer
|
||||
body.WriteString("\r\n")
|
||||
body.WriteString(t.I18nBot("tgbot.messages.qrLegendHeader"))
|
||||
body.WriteString("\r\n")
|
||||
for i, code := range codes {
|
||||
number := strconv.Itoa(i + 1)
|
||||
switch code.kind {
|
||||
case "json":
|
||||
body.WriteString(t.I18nBot("tgbot.messages.qrLegendJSON", "Code=="+number))
|
||||
case "link":
|
||||
body.WriteString(t.I18nBot("tgbot.messages.qrLegendLink",
|
||||
"Code=="+number, "App=="+strconv.Itoa(code.app)))
|
||||
default:
|
||||
body.WriteString(t.I18nBot("tgbot.messages.qrLegendSubscription", "Code=="+number))
|
||||
}
|
||||
body.WriteString("\r\n")
|
||||
}
|
||||
return body.String()
|
||||
}
|
||||
|
||||
// qrSheetPicture draws every code onto one picture in the legend's order and
|
||||
// returns it as an uploadable file; nil means nothing could be rendered.
|
||||
func qrSheetPicture(codes []subscriptionQRCode) *telego.InputFile {
|
||||
tiles := make([]image.Image, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
codePNG, err := qrPNG(code.content, qrTileSize)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to render a subscription QR:", err)
|
||||
continue
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(codePNG))
|
||||
if err != nil {
|
||||
logger.Warning("Failed to decode a subscription QR:", err)
|
||||
continue
|
||||
}
|
||||
tiles = append(tiles, img)
|
||||
}
|
||||
if len(tiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := len(tiles)
|
||||
if cols > qrSheetMaxCols {
|
||||
cols = qrSheetMaxCols
|
||||
}
|
||||
rows := (len(tiles) + cols - 1) / cols
|
||||
width := cols*qrTileSize + (cols+1)*qrSheetPad
|
||||
height := rows*qrTileSize + (rows+1)*qrSheetPad
|
||||
|
||||
// The codes must keep their light background to stay scannable, so the sheet
|
||||
// is white rather than the dark tone the screens use.
|
||||
sheet := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.Draw(sheet, sheet.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
|
||||
|
||||
for i, tile := range tiles {
|
||||
x := qrSheetPad + (i%cols)*(qrTileSize+qrSheetPad)
|
||||
y := qrSheetPad + (i/cols)*(qrTileSize+qrSheetPad)
|
||||
draw.Draw(sheet, image.Rect(x, y, x+qrTileSize, y+qrTileSize), tile, tile.Bounds().Min, draw.Src)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, sheet); err != nil {
|
||||
logger.Warning("Failed to encode the QR sheet:", err)
|
||||
return nil
|
||||
}
|
||||
file := tu.FileFromBytes(buf.Bytes(), "qr.png")
|
||||
return &file
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
func qrPNG(content string, size int) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
size = 256
|
||||
}
|
||||
return qrcode.Encode(content, qrcode.Medium, size)
|
||||
}
|
||||
|
||||
// The client card and its screens (limits, expiry, IP log, TG binding, links,
|
||||
// QR) all redraw the same tracked message, so none leaves the previous behind.
|
||||
|
||||
// clientScreenBody is the shared header of every per-client screen: a user who
|
||||
// tapped through four levels still sees which client they are on.
|
||||
func (t *Tgbot) clientScreenBody(email, title string) string {
|
||||
body := "<b>" + html.EscapeString(email) + "</b>\n"
|
||||
if title != "" {
|
||||
body += html.EscapeString(title) + "\n\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// screenClientIps draws the IP log with refresh and clear.
|
||||
func (t *Tgbot) screenClientIps(chatID int64, email string) {
|
||||
body := t.clientScreenBody(email, "")
|
||||
ips, err := t.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || len(ips) == 0 {
|
||||
body += t.I18nBot("tgbot.noIpRecord")
|
||||
} else {
|
||||
body += t.I18nBot("tgbot.messages.ips", "IPs=="+html.EscapeString(formatIPLog(ips)))
|
||||
}
|
||||
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("ips_refresh "+email))),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clearIPs", t.encodeQuery("clear_ips "+email))),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("client", body, rows...))
|
||||
}
|
||||
|
||||
// formatIPLog renders the stored IP log, which is either a list of addresses or
|
||||
// a list of {ip, timestamp} objects depending on when it was written.
|
||||
func formatIPLog(raw string) string {
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
var entries []ipWithTimestamp
|
||||
if json.Unmarshal([]byte(raw), &entries) == nil && len(entries) > 0 {
|
||||
lines := make([]string, 0, len(entries))
|
||||
for _, item := range entries {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, item.IP)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
var plain []string
|
||||
if json.Unmarshal([]byte(raw), &plain) == nil && len(plain) > 0 {
|
||||
return strings.Join(plain, "\n")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// screenClientTG draws a client's Telegram binding. The user picker is the one
|
||||
// reply keyboard kept: the API has no inline way to request a user.
|
||||
func (t *Tgbot) screenClientTG(chatID int64, email string, withPicker bool) {
|
||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||
if err != nil || client == nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
tgID := t.I18nBot("tgbot.noValue")
|
||||
if client.TgID != 0 {
|
||||
tgID = strconv.FormatInt(client.TgID, 10)
|
||||
}
|
||||
body := t.clientScreenBody(email, "")
|
||||
body += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgID)
|
||||
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
rows := [][]telego.InlineKeyboardButton{}
|
||||
if withPicker {
|
||||
rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.selectTGUser", t.encodeQuery("tgid_pick "+email))))
|
||||
}
|
||||
rows = append(rows,
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("tgid_refresh "+email))),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.removeTGUser", t.encodeQuery("tgid_remove "+email))),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))),
|
||||
t.backRow(),
|
||||
)
|
||||
t.renderScreen(chatID, t.newScreen("client", body, rows...))
|
||||
if withPicker {
|
||||
t.sendTGPicker(chatID, traffic.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// sendTGPicker posts the native user picker — the only way the API asks for a
|
||||
// user. It is a real message, so it carries the hide button and is removed later.
|
||||
func (t *Tgbot) sendTGPicker(chatID int64, trafficID int) {
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.buttons.selectOneTGUser"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
request := telego.KeyboardButtonRequestUsers{RequestID: int32(trafficID), UserIsBot: new(bool)}
|
||||
keyboard := tu.Keyboard(
|
||||
tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&request)),
|
||||
tu.KeyboardRow(tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard"))),
|
||||
).WithIsPersistent().WithResizeKeyboard()
|
||||
_, _ = liveBot().SendMessage(ctx, &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: t.I18nBot("tgbot.buttons.selectOneTGUser"), ReplyMarkup: keyboard,
|
||||
})
|
||||
}
|
||||
|
||||
// ownSubscriptionOnly reports whether the screen is the client's own single
|
||||
// subscription, where admin-only controls must stay off (reached via "Links").
|
||||
func (t *Tgbot) ownSubscriptionOnly(chatID int64, email string) bool {
|
||||
if checkAdmin(chatID) {
|
||||
return false
|
||||
}
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(chatID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(traffics) == 1 && traffics[0].Email == email
|
||||
}
|
||||
|
||||
// screenClientLinks draws the subscription and the links of a client.
|
||||
func (t *Tgbot) screenClientLinks(chatID int64, email string) {
|
||||
subURL, subJSON, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
body := t.clientScreenBody(email, "")
|
||||
body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL))
|
||||
if subJSON != "" {
|
||||
body += t.I18nBot("tgbot.messages.jsonURL", "URL=="+html.EscapeString(subJSON))
|
||||
}
|
||||
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.qrCode", t.encodeQuery("client_qr_links "+email))),
|
||||
}
|
||||
// A client looking at its own subscription has nothing behind the admin's
|
||||
// card: it only repeats the usage already on its home screen.
|
||||
if !t.ownSubscriptionOnly(chatID, email) {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))))
|
||||
}
|
||||
rows = append(rows, t.backRow())
|
||||
t.renderScreen(chatID, t.newScreen("links", body, rows...))
|
||||
}
|
||||
|
||||
// screenIndividualLinks renders the per-app links of the subscription as one
|
||||
// (paged) message instead of one message per app.
|
||||
func (t *Tgbot) screenIndividualLinks(chatID int64, email string) {
|
||||
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
links, err := t.clientSubLinks(email, subURL)
|
||||
if err != nil || len(links) == 0 {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
var body strings.Builder
|
||||
body.WriteString(t.clientScreenBody(email, t.I18nBot("tgbot.buttons.individualLinks")))
|
||||
for _, link := range links {
|
||||
body.WriteString("<code>" + html.EscapeString(link) + "</code>\n")
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("links", body.String(), t.individualLinksRows()...))
|
||||
}
|
||||
|
||||
// individualLinksRows is the individual-links screen's keyboard: exactly one
|
||||
// control, the shared back row, since a second "Links" button was a duplicate.
|
||||
func (t *Tgbot) individualLinksRows() [][]telego.InlineKeyboardButton {
|
||||
return [][]telego.InlineKeyboardButton{t.backRow()}
|
||||
}
|
||||
|
||||
// screenClientQR renders the subscription QR as the screen picture and leaves
|
||||
// the individual QRs to a document, so one screen covers the tap.
|
||||
func (t *Tgbot) screenClientQR(chatID int64, email string) {
|
||||
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
body := t.clientScreenBody(email, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email))
|
||||
body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL))
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))),
|
||||
t.backRow(),
|
||||
}
|
||||
// One picture carrying every code the bot once sent as separate documents;
|
||||
// tiles carry no text (they are scanned), so the caption legend numbers them.
|
||||
codes := t.subscriptionQRCodes(email, subURL)
|
||||
if len(codes) == 0 {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
sc := t.newScreen("qr", body+t.qrCodeLegend(codes), rows...)
|
||||
sc.qrPicture = qrSheetPicture(codes)
|
||||
sc.qrSource = subURL
|
||||
if sc.qrPicture == nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
t.renderScreen(chatID, sc)
|
||||
}
|
||||
|
||||
// renderPickerOnClient redraws the client card with a picker's rows appended, so
|
||||
// tapping a limit opens its value list on the same tracked screen.
|
||||
func (t *Tgbot) renderPickerOnClient(chatID int64, email string, picker [][]telego.InlineKeyboardButton) {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil || traffic == nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
rows := append([][]telego.InlineKeyboardButton{}, picker...)
|
||||
rows = append(rows, t.backRow())
|
||||
body := t.clientScreenBody(email, t.clientInfoMsg(traffic, true, true, true, true, true, false))
|
||||
t.renderScreen(chatID, t.newScreen("client", body, rows...))
|
||||
}
|
||||
|
||||
// renderDraftPicker redraws the add-client draft with a value picker's rows
|
||||
// appended: the wizard's own card stays visible while a value is chosen.
|
||||
func (t *Tgbot) renderDraftPicker(chatID int64, draft *clientDraft, picker [][]telego.InlineKeyboardButton) {
|
||||
rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...)
|
||||
rows = append(rows, picker...)
|
||||
t.renderScreen(chatID, t.newScreen("wizard", t.BuildClientDraftMessage(draft), rows...))
|
||||
}
|
||||
|
||||
// adoptScreen marks the message the user is looking at as this chat's screen,
|
||||
// so the next render edits it instead of stacking a second screen under it.
|
||||
func (t *Tgbot) adoptScreen(chatID int64, msgID int) {
|
||||
if msgID == 0 {
|
||||
return
|
||||
}
|
||||
if _, ok := t.screens().get(chatID); ok {
|
||||
return
|
||||
}
|
||||
t.screens().put(chatID, &screen{msgID: msgID})
|
||||
}
|
||||
|
||||
// clientTrafficID resolves the traffic row id a client's TG-user picker needs.
|
||||
// A missing row is 0: the caller's screen already reports the unknown client.
|
||||
func (t *Tgbot) clientTrafficID(email string) int {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil || traffic == nil {
|
||||
return 0
|
||||
}
|
||||
return traffic.Id
|
||||
}
|
||||
|
||||
// sendNoticeNoKeyboard reports a result and drops the client picker's reply
|
||||
// keyboard in one message: this is the only place it is torn down.
|
||||
func (t *Tgbot) sendNoticeNoKeyboard(chatID int64, text string) {
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML",
|
||||
ReplyMarkup: tu.ReplyKeyboardRemove(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send a notice:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// wizardPrompt asks for a text value on the draft screen itself: the wizard owns
|
||||
// one message, so the question replaces the card; the reply keyboard is untouched.
|
||||
func (t *Tgbot) wizardPrompt(chatID int64, draft *clientDraft, prompt string) {
|
||||
rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...)
|
||||
rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info")))
|
||||
t.renderScreen(chatID, t.newScreen("wizard", prompt, rows...))
|
||||
}
|
||||
|
||||
// wizardInvalidInput redraws the draft with a rejection hint: the wizard keeps
|
||||
// ONE message, so a retry does not stack a hint under a stale card.
|
||||
func (t *Tgbot) wizardInvalidInput(chatID int64, draft *clientDraft) {
|
||||
rows := append([][]telego.InlineKeyboardButton{}, t.getCommonClientButtons(draft)...)
|
||||
rows = append(rows, tu.InlineKeyboardRow(t.btn("tgbot.buttons.use_default", "add_client_default_info")))
|
||||
t.renderScreen(chatID, t.newScreen("wizard",
|
||||
t.BuildClientDraftMessage(draft)+"\n\n"+t.I18nBot("tgbot.messages.incorrect_input"), rows...))
|
||||
}
|
||||
|
||||
// screenInviteLink draws the invite link of a client as instructions plus the
|
||||
// link itself, so an admin can forward it without leaving the screen.
|
||||
func (t *Tgbot) screenInviteLink(chatID int64, email string) {
|
||||
record, err := t.clientService.GetRecordByEmail(nil, email)
|
||||
username := botUsername()
|
||||
if err != nil || record.SubID == "" || username == "" {
|
||||
logger.Warning("tgbot: invite link unavailable for", email, err)
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
payload, ok := encodeInvitePayload(record.SubID)
|
||||
if !ok {
|
||||
logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
link := "https://t.me/" + username + "?start=" + payload
|
||||
body := t.clientScreenBody(email, t.I18nBot("tgbot.buttons.inviteLink"))
|
||||
body += "<code>" + html.EscapeString(link) + "</code>\n\n"
|
||||
body += t.I18nBot("tgbot.messages.inviteHint")
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.clientCard", t.encodeQuery("client_get_usage "+email))),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("client", body, rows...))
|
||||
}
|
||||
|
||||
// openOwnClientScreen resolves the client behind a Telegram user and opens the
|
||||
// asked-for screen; several subscriptions become one button each.
|
||||
func (t *Tgbot) openOwnClientScreen(chatID, tgUserID int64, action string) {
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
||||
if err != nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
||||
return
|
||||
}
|
||||
if len(traffics) == 1 {
|
||||
t.openClientAction(chatID, action, traffics[0].Email)
|
||||
return
|
||||
}
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, tr := range traffics {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).
|
||||
WithCallbackData(t.encodeQuery(action+" "+tr.Email)))
|
||||
}
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
rows := append(keyboard.InlineKeyboard, t.backRow())
|
||||
t.renderScreen(chatID, t.newScreen("links", t.I18nBot("tgbot.commands.pleaseChoose"), rows...))
|
||||
}
|
||||
|
||||
// openClientAction opens one per-client screen by name.
|
||||
func (t *Tgbot) openClientAction(chatID int64, action, email string) {
|
||||
switch action {
|
||||
case "client_sub_links":
|
||||
t.screenClientLinks(chatID, email)
|
||||
case "client_individual_links":
|
||||
t.screenIndividualLinks(chatID, email)
|
||||
case "client_qr_links":
|
||||
t.screenClientQR(chatID, email)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// The bot's lists are browsed in inline mode, which must be enabled by hand in
|
||||
// @BotFather; when off, screens show a hint rather than a silent dead button.
|
||||
|
||||
// The launcher carries its own intent as the query text (empty = all inbounds,
|
||||
// "cl" = every client, "cl <id>" = one inbound's), so nothing is kept server-side.
|
||||
const (
|
||||
inlineScopeInbounds = ""
|
||||
inlineScopeClients = "cl"
|
||||
)
|
||||
|
||||
// scopeQuery is the inline-query text the launcher opens with.
|
||||
func scopeQuery(kind, data string) string {
|
||||
if kind == inlineScopeClients && data != "" {
|
||||
return inlineScopeClients + " " + data
|
||||
}
|
||||
if kind == inlineScopeClients {
|
||||
return inlineScopeClients
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inlineSupport caches getMe().supports_inline_queries. A permanent cache would
|
||||
// freeze a pre-/setinline False forever, so it refreshes once stale.
|
||||
type inlineSupport struct {
|
||||
mu sync.Mutex
|
||||
ok bool
|
||||
known bool
|
||||
at time.Time
|
||||
}
|
||||
|
||||
var inlineCapability = &inlineSupport{}
|
||||
|
||||
const inlineCapabilityTTL = 10 * time.Minute
|
||||
|
||||
// recordInlineCapability stores what getMe() reported.
|
||||
func recordInlineCapability(supported bool) {
|
||||
inlineCapability.mu.Lock()
|
||||
inlineCapability.ok = supported
|
||||
inlineCapability.known = true
|
||||
inlineCapability.at = time.Now()
|
||||
inlineCapability.mu.Unlock()
|
||||
}
|
||||
|
||||
// inlineSupported reports whether the launcher may be shown. A stale value is
|
||||
// served and refreshed off-path; an unknown empty cache must not warn (post-restart bug).
|
||||
func (t *Tgbot) inlineSupported() bool {
|
||||
inlineCapability.mu.Lock()
|
||||
ok, known, at := inlineCapability.ok, inlineCapability.known, inlineCapability.at
|
||||
inlineCapability.mu.Unlock()
|
||||
|
||||
if known && time.Since(at) <= inlineCapabilityTTL {
|
||||
return ok
|
||||
}
|
||||
if !known {
|
||||
t.refreshInlineCapability()
|
||||
inlineCapability.mu.Lock()
|
||||
defer inlineCapability.mu.Unlock()
|
||||
return inlineCapability.ok
|
||||
}
|
||||
go t.refreshInlineCapability()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (t *Tgbot) refreshInlineCapability() {
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
me, err := liveBot().GetMe(ctx)
|
||||
if err != nil {
|
||||
logger.Debug("Cannot read the bot capability for inline mode:", err)
|
||||
return
|
||||
}
|
||||
recordInlineCapability(me.SupportsInlineQueries)
|
||||
}
|
||||
|
||||
// launchRow is the one way into a list: a launcher that opens inline mode in
|
||||
// this chat, or the hint that says why there is none.
|
||||
func (t *Tgbot) launchRow(query string, labelKey string) []telego.InlineKeyboardButton {
|
||||
if !t.inlineSupported() {
|
||||
return tu.InlineKeyboardRow(t.btn("tgbot.buttons.inlineDisabled", "inline_help"))
|
||||
}
|
||||
// Telegram prefills and returns this token as the query, so it is both intent
|
||||
// and filter prefix; not localized because a translated marker could not parse.
|
||||
btn := telego.InlineKeyboardButton{
|
||||
Text: t.I18nBot(labelKey),
|
||||
SwitchInlineQueryCurrentChat: &query,
|
||||
}
|
||||
return tu.InlineKeyboardRow(btn)
|
||||
}
|
||||
|
||||
// Capabilities is the bot's own answer to "can you show lists?": inline mode is
|
||||
// a BotFather switch, so the panel can only warn, never enable it.
|
||||
type Capabilities struct {
|
||||
InlineEnabled bool
|
||||
// GroupPrivacy means the bot CANNOT read every group message, so a /start
|
||||
// typed in a group never reaches it unless the message mentions the liveBot().
|
||||
GroupPrivacy bool
|
||||
Username string
|
||||
Running bool
|
||||
}
|
||||
|
||||
// Capabilities probes the live bot for what it can do.
|
||||
func (t *Tgbot) Capabilities() (Capabilities, error) {
|
||||
caps := Capabilities{Running: t.IsRunning(), Username: botUsername()}
|
||||
if liveBot() == nil {
|
||||
return caps, errors.New("bot is not initialised")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
me, err := liveBot().GetMe(ctx)
|
||||
if err != nil {
|
||||
return caps, err
|
||||
}
|
||||
caps.InlineEnabled = me.SupportsInlineQueries
|
||||
// can_read_all_group_messages is the inverse of BotFather's Group Privacy.
|
||||
caps.GroupPrivacy = !me.CanReadAllGroupMessages
|
||||
caps.Username = me.Username
|
||||
recordInlineCapability(me.SupportsInlineQueries)
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
// screenInlineHelp explains a bot that cannot open lists: inline mode is a
|
||||
// BotFather switch, so the screen tells the admin exactly which one to flip.
|
||||
func (t *Tgbot) screenInlineHelp(chatID int64) {
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.inline.recheck", "inline_recheck")),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("commands",
|
||||
t.I18nBot("tgbot.inline.offTitle")+"\r\n\r\n"+t.I18nBot("tgbot.inline.offBody"), rows...))
|
||||
}
|
||||
|
||||
// handleInlineQuery answers one query with the items of its scope.
|
||||
func (t *Tgbot) handleInlineQuery(query *telego.InlineQuery) {
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
level := t.levelOf(query.From.ID)
|
||||
if level == levelStranger {
|
||||
t.answerInline(query.ID, nil, "")
|
||||
return
|
||||
}
|
||||
// Telegram prefills the typed text with the launcher's query, so the intent
|
||||
// arrives before anything the user adds; a bare query means inbounds.
|
||||
action, data, search := parseInlineQuery(query.Query)
|
||||
results := t.inlineResults(action, data, search, level == levelAdmin)
|
||||
t.answerInline(query.ID, results, query.Offset)
|
||||
}
|
||||
|
||||
// maxInlineResults is the Bot API's hard cap on one answer. Exceeding it gets
|
||||
// the whole answer rejected, which on a big panel means an empty list forever.
|
||||
const maxInlineResults = 50
|
||||
|
||||
// pageInlineResults returns one page and the cursor for the next. The full list
|
||||
// is built first because the cap is a property of the transport, not of a list.
|
||||
func pageInlineResults(results []telego.InlineQueryResult, offset string) (page []telego.InlineQueryResult, next string) {
|
||||
start, err := strconv.Atoi(offset)
|
||||
if err != nil || start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start >= len(results) {
|
||||
return nil, ""
|
||||
}
|
||||
end := start + maxInlineResults
|
||||
if end < len(results) {
|
||||
return results[start:end], strconv.Itoa(end)
|
||||
}
|
||||
return results[start:], ""
|
||||
}
|
||||
|
||||
// markerResultID keeps a result id inside the 64-byte cap Telegram enforces: one
|
||||
// long email would otherwise sink the entire answer.
|
||||
func markerResultID(marker string) string {
|
||||
if len(marker) <= 63 {
|
||||
return marker
|
||||
}
|
||||
sum := sha256.Sum256([]byte(marker))
|
||||
return "h:" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// answerInline answers with page, or with the "nothing here" card: an
|
||||
// unanswered query leaves Telegram spinning.
|
||||
func (t *Tgbot) answerInline(queryID string, results []telego.InlineQueryResult, offset string) {
|
||||
page, next := pageInlineResults(results, offset)
|
||||
if len(results) == 0 {
|
||||
hint := t.I18nBot("tgbot.inline.noneTitle")
|
||||
page = []telego.InlineQueryResult{&telego.InlineQueryResultArticle{
|
||||
Type: "article",
|
||||
ID: "none",
|
||||
Title: hint,
|
||||
Description: t.I18nBot("tgbot.inline.noneDesc"),
|
||||
InputMessageContent: &telego.InputTextMessageContent{
|
||||
MessageText: "none:" + hint,
|
||||
},
|
||||
}}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
err := liveBot().AnswerInlineQuery(ctx, &telego.AnswerInlineQueryParams{
|
||||
InlineQueryID: queryID,
|
||||
Results: page,
|
||||
NextOffset: next,
|
||||
CacheTime: 0,
|
||||
IsPersonal: true,
|
||||
})
|
||||
if err != nil {
|
||||
// A stale query id is normal (the user kept typing); never a failure.
|
||||
logger.Debug("answerInlineQuery failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// inlineResults builds one inline query's items. Inbound lists are admin-only:
|
||||
// that browser leaked every inbound's remark, protocol, traffic and client count.
|
||||
func (t *Tgbot) inlineResults(action, data, search string, isAdmin bool) []telego.InlineQueryResult {
|
||||
if action == inlineScopeClients {
|
||||
return t.clientResults(search, isAdmin, data)
|
||||
}
|
||||
if !isAdmin {
|
||||
return nil
|
||||
}
|
||||
if action == "add" {
|
||||
return t.inboundResults(search, "add")
|
||||
}
|
||||
return t.inboundResults(search, "inb")
|
||||
}
|
||||
|
||||
// parseInlineQuery splits "cl 3 user@x" into its action, inbound and the text to
|
||||
// filter by. Nothing found means the inbounds list.
|
||||
func parseInlineQuery(text string) (action, data, search string) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
lower := strings.ToLower(trimmed)
|
||||
for _, marker := range []string{inlineScopeClients, "add"} {
|
||||
if !strings.HasPrefix(lower, marker) {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimSpace(trimmed[len(marker):])
|
||||
if marker == inlineScopeClients {
|
||||
// "cl 3 name" — the first token is the inbound when it is a number.
|
||||
fields := strings.SplitN(rest, " ", 2)
|
||||
if len(fields) > 0 && fields[0] != "" {
|
||||
if _, err := strconv.Atoi(fields[0]); err == nil {
|
||||
if len(fields) == 2 {
|
||||
return marker, fields[0], fields[1]
|
||||
}
|
||||
return marker, fields[0], ""
|
||||
}
|
||||
}
|
||||
return marker, "", rest
|
||||
}
|
||||
return marker, "", rest
|
||||
}
|
||||
return inlineScopeInbounds, "", trimmed
|
||||
}
|
||||
|
||||
// inboundResults lists inbounds as markers. action is what a chosen item means:
|
||||
// "inb" opens the inbound, "add" starts the add-client wizard on it.
|
||||
func (t *Tgbot) inboundResults(search, action string) []telego.InlineQueryResult {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Cannot list inbounds for inline mode:", err)
|
||||
return nil
|
||||
}
|
||||
needle := strings.ToLower(strings.TrimSpace(search))
|
||||
results := make([]telego.InlineQueryResult, 0, len(inbounds))
|
||||
for _, ib := range inbounds {
|
||||
remark := ib.Remark
|
||||
if remark == "" {
|
||||
remark = "#" + strconv.Itoa(ib.Id)
|
||||
}
|
||||
status := "❌"
|
||||
if ib.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
description := fmt.Sprintf("#%d · %s · %s · 🚦 %s\n👥 %d", ib.Id, ib.Protocol, status,
|
||||
common.FormatTraffic(ib.Up+ib.Down), len(ib.ClientStats))
|
||||
if needle != "" && !strings.Contains(strings.ToLower(remark+" "+string(ib.Protocol)+" "+strconv.Itoa(ib.Port)), needle) {
|
||||
continue
|
||||
}
|
||||
marker := action + ":" + strconv.Itoa(ib.Id)
|
||||
results = append(results, &telego.InlineQueryResultArticle{
|
||||
Type: "article",
|
||||
ID: markerResultID(marker),
|
||||
Title: remark,
|
||||
Description: description,
|
||||
InputMessageContent: &telego.InputTextMessageContent{
|
||||
MessageText: marker,
|
||||
},
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// clientResults lists clients of one inbound, or of all of them when inboundID
|
||||
// is empty: an admin browses the panel, a client only ever sees its own.
|
||||
func (t *Tgbot) clientResults(search string, isAdmin bool, inboundID string) []telego.InlineQueryResult {
|
||||
needle := strings.ToLower(strings.TrimSpace(search))
|
||||
results := make([]telego.InlineQueryResult, 0, 32)
|
||||
|
||||
add := func(email, remark string, used, limit, expiry int64, enabled, online bool) {
|
||||
if needle != "" && !strings.Contains(strings.ToLower(email+" "+remark), needle) {
|
||||
return
|
||||
}
|
||||
marker := "cl:" + email
|
||||
results = append(results, &telego.InlineQueryResultArticle{
|
||||
Type: "article",
|
||||
ID: markerResultID(marker),
|
||||
Title: email, // the title is the one thing every client shows
|
||||
// Telegram renders about three lines here, so the three facts a
|
||||
// client screen answers first get one line each.
|
||||
Description: t.clientCardLines(remark, used, limit, expiry, enabled, online),
|
||||
InputMessageContent: &telego.InputTextMessageContent{
|
||||
MessageText: marker,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if !isAdmin {
|
||||
return results
|
||||
}
|
||||
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Cannot list clients for inline mode:", err)
|
||||
return results
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, ib := range inbounds {
|
||||
if inboundID != "" && strconv.Itoa(ib.Id) != inboundID {
|
||||
continue
|
||||
}
|
||||
clients, err := t.inboundService.GetClients(ib)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, client := range clients {
|
||||
if seen[client.Email] {
|
||||
continue
|
||||
}
|
||||
seen[client.Email] = true
|
||||
used := int64(0)
|
||||
if traffic, err := t.inboundService.GetClientTrafficByEmail(client.Email); err == nil && traffic != nil {
|
||||
used = traffic.Up + traffic.Down
|
||||
}
|
||||
add(client.Email, ib.Remark, used, client.TotalGB, client.ExpiryTime, client.Enable, false)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// clientCardLines is an inline result's description: three lines, because that
|
||||
// is what Telegram renders under the title.
|
||||
func (t *Tgbot) clientCardLines(remark string, used, limit, expiry int64, enabled, online bool) string {
|
||||
head := "❌"
|
||||
if enabled {
|
||||
head = "✅"
|
||||
}
|
||||
if online {
|
||||
head += " 🟢"
|
||||
}
|
||||
if remark != "" {
|
||||
head += " · " + remark
|
||||
}
|
||||
|
||||
traffic := common.FormatTraffic(used)
|
||||
if limit > 0 {
|
||||
// The percentage is what people actually compare between clients.
|
||||
traffic += " / " + common.FormatTraffic(limit) +
|
||||
fmt.Sprintf(" · %d%%", used*100/limit)
|
||||
} else {
|
||||
traffic += " / " + t.I18nBot("tgbot.unlimited")
|
||||
}
|
||||
|
||||
expiryText := t.I18nBot("tgbot.unlimited")
|
||||
switch {
|
||||
case expiry < 0:
|
||||
// A negative term is days counted from first use, not a date.
|
||||
expiryText = t.durationText(-expiry*86400000, true)
|
||||
case expiry > 0:
|
||||
expiryText = t.durationText(expiry-time.Now().UnixMilli(), false)
|
||||
}
|
||||
return head + "\n🚦 " + traffic + "\n📅 " + expiryText
|
||||
}
|
||||
|
||||
// durationText is a compact, localized span: "12 Minutes", "5 Hours", "30 Days".
|
||||
// fromFirstUse names the not-yet-started case, whose clock has not begun.
|
||||
func (t *Tgbot) durationText(ms int64, fromFirstUse bool) string {
|
||||
if ms <= 0 {
|
||||
return t.I18nBot("tgbot.inline.expired")
|
||||
}
|
||||
if fromFirstUse {
|
||||
return fmt.Sprintf("%d %s (%s)", ms/86400000, t.I18nBot("tgbot.days"),
|
||||
t.I18nBot("tgbot.inline.fromFirstUse"))
|
||||
}
|
||||
switch {
|
||||
case ms >= 30*86400000:
|
||||
return fmt.Sprintf("%d %s", ms/(30*86400000), t.I18nBot("tgbot.months"))
|
||||
case ms >= 86400000:
|
||||
return fmt.Sprintf("%d %s", ms/86400000, t.I18nBot("tgbot.days"))
|
||||
case ms >= 3600000:
|
||||
return fmt.Sprintf("%d %s", ms/3600000, t.I18nBot("tgbot.hours"))
|
||||
default:
|
||||
return fmt.Sprintf("%d %s", ms/60000, t.I18nBot("tgbot.minutes"))
|
||||
}
|
||||
}
|
||||
|
||||
// handleListMarker catches an item picked in inline mode. The marker arrives as
|
||||
// the user's own message, so it is deleted first, then the real card is drawn.
|
||||
func (t *Tgbot) handleListMarker(message *telego.Message) bool {
|
||||
if message == nil || message.Text == "" {
|
||||
return false
|
||||
}
|
||||
action, value, ok := splitListMarker(message.Text)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
level := t.levelOf(message.From.ID)
|
||||
if level == levelStranger {
|
||||
return true
|
||||
}
|
||||
if !t.markerAllowed(action, value, message.From.ID, level == levelAdmin) {
|
||||
return true
|
||||
}
|
||||
// Only a marker that actually opens something is litter worth removing.
|
||||
t.deleteIncoming(message)
|
||||
|
||||
switch action {
|
||||
case "inb":
|
||||
id, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
t.screenInboundClients(message.Chat.ID, id)
|
||||
case "add":
|
||||
id, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
t.startAddClientForInbound(message.Chat.ID, message.From.ID, id)
|
||||
case "cl":
|
||||
if t.clientOwnedByTgUser(message.From.ID, value) || level == levelAdmin {
|
||||
t.searchClientScreen(message.Chat.ID, value)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// markerAllowed checks the marker against the sender: an admin marker needs the
|
||||
// admin level, a client marker must name a client that account owns.
|
||||
func (t *Tgbot) markerAllowed(action, value string, tgUserID int64, isAdmin bool) bool {
|
||||
switch action {
|
||||
case "inb", "add":
|
||||
return isAdmin
|
||||
case "cl":
|
||||
if isAdmin {
|
||||
return true
|
||||
}
|
||||
return t.clientOwnedByTgUser(tgUserID, value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// messageIsListMarker matches only a picked inline item; a "text contains a
|
||||
// colon" predicate once swallowed broadcasts and wizard messages (URL, time, comment).
|
||||
func messageIsListMarker(_ context.Context, update telego.Update) bool {
|
||||
if update.Message == nil {
|
||||
return false
|
||||
}
|
||||
_, _, ok := splitListMarker(update.Message.Text)
|
||||
return ok
|
||||
}
|
||||
|
||||
// splitListMarker parses "inb:3", "add:3" or "cl:user@x".
|
||||
func splitListMarker(text string) (action, value string, ok bool) {
|
||||
for _, candidate := range []string{"inb:", "add:", "cl:"} {
|
||||
if rest, found := strings.CutPrefix(text, candidate); found && rest != "" {
|
||||
return strings.TrimSuffix(candidate, ":"), rest, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// screenInboundClients draws one inbound's clients, listed by the inline query.
|
||||
func (t *Tgbot) screenInboundClients(chatID int64, inboundID int) {
|
||||
inbound, err := t.inboundService.GetInbound(inboundID)
|
||||
if err != nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
return
|
||||
}
|
||||
// The clients are browsed in inline mode, never as a callback-button grid:
|
||||
// a big inbound would otherwise fill the screen with one button per client.
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
t.launchRow(scopeQuery(inlineScopeClients, strconv.Itoa(inboundID)), "tgbot.buttons.searchClients"),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("clients", t.inboundSummaryText(inbound), rows...))
|
||||
}
|
||||
|
||||
// screenAllClients is the panel-wide client browser. There is no inbound step:
|
||||
// the list is every client, and a tap opens the client's own screen.
|
||||
func (t *Tgbot) screenAllClients(chatID int64) {
|
||||
body := t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(t.clientCount()))
|
||||
body += t.I18nBot("tgbot.messages.inlineHint")
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
t.launchRow(scopeQuery(inlineScopeClients, ""), "tgbot.buttons.searchClients"),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("clients", body, rows...))
|
||||
}
|
||||
|
||||
// clientCount is how many clients the panel holds, for the browser's header.
|
||||
func (t *Tgbot) clientCount() int {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Cannot count clients for the browser:", err)
|
||||
return 0
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, ib := range inbounds {
|
||||
clients, err := t.inboundService.GetClients(ib)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, client := range clients {
|
||||
seen[client.Email] = true
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// inboundSummaryText is one inbound's header line: remark, protocol, port.
|
||||
func (t *Tgbot) inboundSummaryText(inbound *model.Inbound) string {
|
||||
body := t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
||||
body += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
||||
body += t.I18nBot("tgbot.messages.traffic",
|
||||
"Total=="+common.FormatTraffic(inbound.Up+inbound.Down),
|
||||
"Upload=="+common.FormatTraffic(inbound.Up),
|
||||
"Download=="+common.FormatTraffic(inbound.Down))
|
||||
if inbound.ExpiryTime == 0 {
|
||||
body += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
||||
} else {
|
||||
body += t.I18nBot("tgbot.messages.expire",
|
||||
"Time=="+time.Unix(inbound.ExpiryTime/1000, 0).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
body += t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(len(inbound.ClientStats)))
|
||||
return body
|
||||
}
|
||||
|
||||
// startAddClientForInbound opens the add-client wizard on a chosen inbound.
|
||||
func (t *Tgbot) startAddClientForInbound(chatID, userID int64, inboundID int) {
|
||||
if _, err := t.inboundService.GetInbound(inboundID); err != nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
return
|
||||
}
|
||||
draft := addClientDrafts.forActor(chatUser{chatID: chatID, userID: userID})
|
||||
draft.Lock()
|
||||
defer draft.Unlock()
|
||||
t.resetDraft(draft)
|
||||
draft.receiverInboundID = inboundID
|
||||
draft.receiverInboundIDs = []int{inboundID}
|
||||
t.addClient(chatID, draft, t.BuildClientDraftMessage(draft))
|
||||
}
|
||||
|
||||
// resetDraft clears the wizard back to its defaults for a fresh client.
|
||||
func (t *Tgbot) resetDraft(draft *clientDraft) {
|
||||
draft.email = t.randomLowerAndNum(8)
|
||||
draft.limitIP = 0
|
||||
draft.totalGB = 0
|
||||
draft.expiryTime = 0
|
||||
draft.enable = true
|
||||
draft.tgID = ""
|
||||
draft.subID = t.randomLowerAndNum(16)
|
||||
draft.comment = ""
|
||||
draft.reset = 0
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
// inlineCapturingServer records answerInlineQuery results so a test can assert
|
||||
// on the list Telegram would show, and on the marker an item carries.
|
||||
func inlineCapturingServer(t *testing.T, supportsInline bool) (*httptest.Server, func() []map[string]any) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var queries []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch method {
|
||||
case "answerInlineQuery":
|
||||
mu.Lock()
|
||||
queries = append(queries, payload)
|
||||
mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true})
|
||||
case "getMe":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
||||
"id": 42, "is_bot": true, "first_name": "bot", "username": "bot",
|
||||
"supports_inline_queries": supportsInline,
|
||||
}})
|
||||
case "getChat":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
||||
"id": 42, "type": "private", "photo": map[string]any{"big_file_id": "big"},
|
||||
}})
|
||||
case "sendMessage", "sendPhoto", "sendDocument":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
||||
"message_id": 5, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
|
||||
}})
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true})
|
||||
}
|
||||
}))
|
||||
return srv, func() []map[string]any {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]map[string]any(nil), queries...)
|
||||
}
|
||||
}
|
||||
|
||||
// maybeAnswerInline waits briefly for the asynchronous query answer.
|
||||
func maybeAnswerInline(get func() []map[string]any) []map[string]any {
|
||||
for range 20 {
|
||||
if got := get(); len(got) > 0 {
|
||||
return got
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return get()
|
||||
}
|
||||
|
||||
func TestInlineQueryListsInboundsWithMarkers(t *testing.T) {
|
||||
srv, queries := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
seedReportClients(t, "nl-fast", []string{"a@x"})
|
||||
|
||||
tb.handleInlineQuery(&telego.InlineQuery{
|
||||
ID: "iq1",
|
||||
From: telego.User{ID: 1},
|
||||
Query: "",
|
||||
})
|
||||
|
||||
got := maybeAnswerInline(queries)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("answerInlineQuery calls = %d, want 1", len(got))
|
||||
}
|
||||
results, _ := got[0]["results"].([]any)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("inline results are empty, want the seeded inbound")
|
||||
}
|
||||
item, _ := results[0].(map[string]any)
|
||||
id, _ := item["id"].(string)
|
||||
if !strings.HasPrefix(id, "inb:") {
|
||||
t.Errorf("result id = %q, want an inb: marker", id)
|
||||
}
|
||||
content, _ := item["input_message_content"].(map[string]any)
|
||||
if text, _ := content["message_text"].(string); text != id {
|
||||
t.Errorf("marker text = %q, want the marker itself (%q), never the real content", text, id)
|
||||
}
|
||||
}
|
||||
|
||||
// /start must draw the menu; it also reaches the route the colon predicate guards, pinning that a
|
||||
// command is never mistaken for an inline marker.
|
||||
func TestStartCommandDrawsTheMenu(t *testing.T) {
|
||||
srv, calls := recordingServer(t, false)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
setTestAdmins(t, 1)
|
||||
|
||||
tb := initReportDB(t)
|
||||
|
||||
tb.answerCommand(&telego.Message{
|
||||
MessageID: 1,
|
||||
Chat: telego.Chat{ID: 500, Type: "private"},
|
||||
From: &telego.User{ID: 1},
|
||||
Text: "/start",
|
||||
}, 500, true)
|
||||
|
||||
got := calls()
|
||||
if len(got) == 0 {
|
||||
t.Fatal("an admin's /start drew nothing")
|
||||
}
|
||||
// The menu is drawn as a photo when art is cached and as text otherwise;
|
||||
// either way the call has to be there.
|
||||
drew := false
|
||||
for _, c := range got {
|
||||
if c.Method == "sendPhoto" || c.Method == "sendMessage" {
|
||||
drew = true
|
||||
}
|
||||
}
|
||||
if !drew {
|
||||
t.Errorf("calls = %v, want the menu message", methods(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A stranger's /start reports its own id, which is what an admin needs to bind
|
||||
// it; it must never render the panel menu.
|
||||
func TestStartCommandForAStrangerRevealsOnlyItsID(t *testing.T) {
|
||||
srv, calls := recordingServer(t, false)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
setTestAdmins(t, 1)
|
||||
|
||||
tb := initReportDB(t)
|
||||
|
||||
tb.answerCommand(&telego.Message{
|
||||
MessageID: 1,
|
||||
Chat: telego.Chat{ID: 777, Type: "private"},
|
||||
From: &telego.User{ID: 777777},
|
||||
Text: "/start",
|
||||
}, 777, false)
|
||||
|
||||
got := calls()
|
||||
if len(got) == 0 {
|
||||
t.Fatal("a stranger's /start got no answer at all")
|
||||
}
|
||||
// The locale key is what the test environment resolves to; the panel's own
|
||||
// bundle renders it as the stranger's id, which an admin needs to bind it.
|
||||
if !strings.Contains(got[0].text(), "askToAddUserId") {
|
||||
t.Errorf("answer = %q, want the bind-your-id notice", got[0].text())
|
||||
}
|
||||
}
|
||||
|
||||
// A client list stays scoped to the inbound its screen named, through the inline query, which carries
|
||||
// the user's id, not the group chat's.
|
||||
func TestInlineQueryListsClientsOfTheChosenInbound(t *testing.T) {
|
||||
srv, queries := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
inlineCapability.recordForTest(true)
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
seedReportClients(t, "nl-fast", []string{"a@x"})
|
||||
seedReportClients(t, "de-slow", []string{"b@x"})
|
||||
|
||||
inbounds, err := tb.inboundService.GetAllInbounds()
|
||||
if err != nil || len(inbounds) != 2 {
|
||||
t.Fatalf("seeded inbounds = %d (err %v), want 2", len(inbounds), err)
|
||||
}
|
||||
chosen := inbounds[0]
|
||||
|
||||
// The screen is drawn in a group: chat id 500, the admin's own id 1.
|
||||
tb.screenInboundClients(500, chosen.Id)
|
||||
|
||||
// The launcher prefills the query with the browser's intent; that text is
|
||||
// what Telegram sends back, so the test sends exactly it.
|
||||
sc, ok := tb.screens().get(500)
|
||||
if !ok {
|
||||
t.Fatal("the client screen was not rendered")
|
||||
}
|
||||
var queryText string
|
||||
for _, row := range sc.markup.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.SwitchInlineQueryCurrentChat != nil {
|
||||
queryText = *btn.SwitchInlineQueryCurrentChat
|
||||
}
|
||||
}
|
||||
}
|
||||
if queryText == "" {
|
||||
t.Fatal("the client screen carries no inline launcher")
|
||||
}
|
||||
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq4", From: telego.User{ID: 1}, Query: queryText})
|
||||
|
||||
got := maybeAnswerInline(queries)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("answerInlineQuery calls = %d, want 1", len(got))
|
||||
}
|
||||
results, _ := got[0]["results"].([]any)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %d, want exactly the chosen inbound's single client", len(results))
|
||||
}
|
||||
item, _ := results[0].(map[string]any)
|
||||
if id, _ := item["id"].(string); !strings.HasPrefix(id, "cl:") {
|
||||
t.Errorf("result id = %q, want a cl: marker, never an inbound", id)
|
||||
}
|
||||
if title, _ := item["title"].(string); title != "a@x" {
|
||||
t.Errorf("result title = %q, want the client of the chosen inbound", title)
|
||||
}
|
||||
}
|
||||
|
||||
// The client screen is browsed in inline mode only: a callback-button grid of
|
||||
// every client is what the screen layer exists to remove.
|
||||
func TestClientScreenCarriesNoClientButtons(t *testing.T) {
|
||||
srv, _ := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
inlineCapability.recordForTest(true)
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
seedReportClients(t, "nl-fast", []string{"a@x", "b@x"})
|
||||
|
||||
inbounds, err := tb.inboundService.GetAllInbounds()
|
||||
if err != nil || len(inbounds) == 0 {
|
||||
t.Fatalf("seeded inbounds = %d (err %v), want at least 1", len(inbounds), err)
|
||||
}
|
||||
tb.screenInboundClients(500, inbounds[0].Id)
|
||||
|
||||
sc, ok := tb.screens().get(500)
|
||||
if !ok || sc.markup == nil {
|
||||
t.Fatal("the client screen was not rendered")
|
||||
}
|
||||
var buttons []string
|
||||
for _, row := range sc.markup.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
buttons = append(buttons, btn.CallbackData)
|
||||
}
|
||||
}
|
||||
for _, data := range buttons {
|
||||
if strings.Contains(data, "client_get_usage") || strings.Contains(data, "@x") {
|
||||
t.Errorf("the client screen carries the client button %q; clients are inline-only", data)
|
||||
}
|
||||
}
|
||||
if len(buttons) == 0 {
|
||||
t.Error("the client screen lost its buttons entirely")
|
||||
}
|
||||
}
|
||||
|
||||
// A query with no list screen behind it must fall back to inbounds: listing
|
||||
// every client would hand the panel's client names to anyone who can type.
|
||||
func TestInlineQueryWithoutScopeListsInboundsNotClients(t *testing.T) {
|
||||
srv, queries := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
inlineCapability.recordForTest(true)
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
seedReportClients(t, "nl-fast", []string{"a@x"})
|
||||
|
||||
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq5", From: telego.User{ID: 1}, Query: ""})
|
||||
|
||||
got := maybeAnswerInline(queries)
|
||||
results, _ := got[0]["results"].([]any)
|
||||
for _, raw := range results {
|
||||
item, _ := raw.(map[string]any)
|
||||
if id, _ := item["id"].(string); strings.HasPrefix(id, "cl:") {
|
||||
t.Fatalf("an unscoped query leaked the client %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown capability cache must not answer "inline is off": after a restart it warned a bot whose
|
||||
// inline mode was on until the background refresh landed.
|
||||
func TestInlineSupportProbesWhenUnknown(t *testing.T) {
|
||||
srv, _ := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
|
||||
// Fresh process: nothing was recorded yet.
|
||||
inlineCapability.mu.Lock()
|
||||
inlineCapability.ok, inlineCapability.known = false, false
|
||||
inlineCapability.mu.Unlock()
|
||||
|
||||
tb := &Tgbot{}
|
||||
if !tb.inlineSupported() {
|
||||
t.Error("an unknown cache answered inline-off; the probe must run first")
|
||||
}
|
||||
if !inlineCapability.known {
|
||||
t.Error("the probe did not record the capability")
|
||||
}
|
||||
}
|
||||
|
||||
// An inline client card is what a user reads before tapping: the email as the
|
||||
// title, and three lines of facts under it.
|
||||
func TestInlineClientCardCarriesTrafficAndExpiry(t *testing.T) {
|
||||
srv, queries := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
inlineCapability.recordForTest(true)
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
seedReportClients(t, "nl-fast", []string{"a@x"})
|
||||
|
||||
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq7", From: telego.User{ID: 1}, Query: inlineScopeClients})
|
||||
|
||||
got := maybeAnswerInline(queries)
|
||||
results, _ := got[0]["results"].([]any)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("the client browser returned nothing")
|
||||
}
|
||||
item, _ := results[0].(map[string]any)
|
||||
if title, _ := item["title"].(string); title != "a@x" {
|
||||
t.Errorf("title = %q, want the client's email", title)
|
||||
}
|
||||
desc, _ := item["description"].(string)
|
||||
if strings.Count(desc, "\n") != 2 {
|
||||
t.Errorf("description = %q, want three lines (two newlines)", desc)
|
||||
}
|
||||
for _, want := range []string{"🚦", "📅"} {
|
||||
if !strings.Contains(desc, want) {
|
||||
t.Errorf("description = %q, want the %s line", desc, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All clients is its own browser: it lists clients straight away and never asks
|
||||
// for an inbound first.
|
||||
func TestAllClientsScreenListsClientsWithoutAnInboundStep(t *testing.T) {
|
||||
srv, queries := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
inlineCapability.recordForTest(true)
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
seedReportClients(t, "nl-fast", []string{"a@x", "b@x"})
|
||||
|
||||
tb.screenAllClients(500)
|
||||
|
||||
sc, ok := tb.screens().get(500)
|
||||
if !ok || sc.markup == nil {
|
||||
t.Fatal("All clients drew no screen")
|
||||
}
|
||||
var queryText string
|
||||
for _, row := range sc.markup.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.SwitchInlineQueryCurrentChat != nil {
|
||||
queryText = *btn.SwitchInlineQueryCurrentChat
|
||||
}
|
||||
}
|
||||
}
|
||||
if queryText != inlineScopeClients {
|
||||
t.Fatalf("launcher query = %q, want the all-clients marker %q", queryText, inlineScopeClients)
|
||||
}
|
||||
|
||||
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq6", From: telego.User{ID: 1}, Query: queryText})
|
||||
|
||||
got := maybeAnswerInline(queries)
|
||||
results, _ := got[0]["results"].([]any)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want both seeded clients", len(results))
|
||||
}
|
||||
for _, raw := range results {
|
||||
item, _ := raw.(map[string]any)
|
||||
if id, _ := item["id"].(string); !strings.HasPrefix(id, "cl:") {
|
||||
t.Errorf("result id = %q, want a cl: marker, never an inbound", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The query text is the whole intent, so its parsing is pinned: a server-side
|
||||
// scope is what kept sending the client step back to the inbound list.
|
||||
func TestParseInlineQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
text, action, data, search string
|
||||
}{
|
||||
{"", inlineScopeInbounds, "", ""},
|
||||
{"nl", inlineScopeInbounds, "", "nl"},
|
||||
{inlineScopeClients, inlineScopeClients, "", ""},
|
||||
{inlineScopeClients + " 3", inlineScopeClients, "3", ""},
|
||||
{inlineScopeClients + " 3 vas", inlineScopeClients, "3", "vas"},
|
||||
{inlineScopeClients + " vas", inlineScopeClients, "", "vas"},
|
||||
{"add", "add", "", ""},
|
||||
{"add nl", "add", "", "nl"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
action, data, search := parseInlineQuery(c.text)
|
||||
if action != c.action || data != c.data || search != c.search {
|
||||
t.Errorf("parseInlineQuery(%q) = (%q, %q, %q), want (%q, %q, %q)",
|
||||
c.text, action, data, search, c.action, c.data, c.search)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineQueryAnswersEmptyResult(t *testing.T) {
|
||||
srv, queries := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
|
||||
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq2", From: telego.User{ID: 1}, Query: "nothing-matches-this"})
|
||||
|
||||
got := maybeAnswerInline(queries)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("answerInlineQuery calls = %d, want 1: an empty list must still answer", len(got))
|
||||
}
|
||||
if results, _ := got[0]["results"].([]any); len(results) == 0 {
|
||||
t.Error("results are empty, want the none-found card")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineQueryRefusesAStranger(t *testing.T) {
|
||||
srv, queries := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
|
||||
tb := initReportDB(t)
|
||||
setTestAdmins(t, 1)
|
||||
seedReportClients(t, "secret", []string{"a@x"})
|
||||
|
||||
// 777777 owns no client and is not an admin.
|
||||
tb.handleInlineQuery(&telego.InlineQuery{ID: "iq3", From: telego.User{ID: 777777}})
|
||||
|
||||
got := maybeAnswerInline(queries)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("answerInlineQuery calls = %d, want 1", len(got))
|
||||
}
|
||||
results, _ := got[0]["results"].([]any)
|
||||
for _, raw := range results {
|
||||
item, _ := raw.(map[string]any)
|
||||
if id, _ := item["id"].(string); strings.HasPrefix(id, "inb:") || strings.HasPrefix(id, "cl:") {
|
||||
t.Fatalf("a stranger received the results item %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineLauncherOnlyWhenInlineIsOn(t *testing.T) {
|
||||
srv, _ := inlineCapturingServer(t, true)
|
||||
swapTestBot(t, srv.URL)
|
||||
defer srv.Close()
|
||||
resetScreenArt()
|
||||
inlineCapability.recordForTest(true)
|
||||
|
||||
tb := &Tgbot{}
|
||||
row := tb.launchRow("inb", "tgbot.buttons.searchInbounds")
|
||||
if len(row) != 1 || row[0].SwitchInlineQueryCurrentChat == nil {
|
||||
t.Fatalf("launcher row = %+v, want a switch-inline button", row)
|
||||
}
|
||||
|
||||
inlineCapability.recordForTest(false)
|
||||
row = tb.launchRow("inb", "tgbot.buttons.searchInbounds")
|
||||
if len(row) != 1 || row[0].SwitchInlineQueryCurrentChat != nil {
|
||||
t.Fatalf("row = %+v, want the inline-off hint instead of a dead button", row)
|
||||
}
|
||||
if row[0].CallbackData != "inline_help" {
|
||||
t.Errorf("hint callback = %q, want inline_help", row[0].CallbackData)
|
||||
}
|
||||
}
|
||||
|
||||
// setTestAdmins registers admins for the duration of a test.
|
||||
func setTestAdmins(t *testing.T, ids ...int64) {
|
||||
t.Helper()
|
||||
orig := adminSnapshot()
|
||||
tgBotMutex.Lock()
|
||||
adminIds = ids
|
||||
tgBotMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
tgBotMutex.Lock()
|
||||
adminIds = orig
|
||||
tgBotMutex.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// recordForTest pins the capability cache without a round trip.
|
||||
func (s *inlineSupport) recordForTest(supported bool) {
|
||||
s.mu.Lock()
|
||||
s.ok = supported
|
||||
s.known = true
|
||||
s.at = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestListMarkerParsesAndRejectsForeignPrefixes(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
text string
|
||||
action string
|
||||
value string
|
||||
ok bool
|
||||
}{
|
||||
{"inb:3", "inb", "3", true},
|
||||
{"add:12", "add", "12", true},
|
||||
{"cl:user@x", "cl", "user@x", true},
|
||||
{"hello world", "", "", false},
|
||||
{"inb:", "", "", false},
|
||||
{"http://x", "", "", false},
|
||||
} {
|
||||
action, value, ok := splitListMarker(tt.text)
|
||||
if ok != tt.ok || action != tt.action || value != tt.value {
|
||||
t.Errorf("splitListMarker(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tt.text, action, value, ok, tt.action, tt.value, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A picked item is the only thing the marker route may match. The predicate used
|
||||
// to be "text contains a colon", which ate broadcast text with a URL or a time.
|
||||
func TestOnlyAPickedItemMatchesTheMarkerRoute(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"inb:3", true},
|
||||
{"add:12", true},
|
||||
{"cl:user@x", true},
|
||||
{"https://example.com/a", false},
|
||||
{"backup at 03:00", false},
|
||||
{"Paid: 2026-11", false},
|
||||
{"note: see panel", false},
|
||||
{"inb:", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
update := telego.Update{Message: &telego.Message{Text: c.text, From: &telego.User{ID: 1}}}
|
||||
if got := messageIsListMarker(context.Background(), update); got != c.want {
|
||||
t.Errorf("messageIsListMarker(%q) = %v, want %v", c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram rejects an answer with more than 50 results, so a big panel has to
|
||||
// page: an un-paged answer is rejected whole and the list looks empty forever.
|
||||
func TestBigListIsPagedInsteadOfRejected(t *testing.T) {
|
||||
all := make([]telego.InlineQueryResult, 0, 120)
|
||||
for i := range 120 {
|
||||
all = append(all, &telego.InlineQueryResultArticle{
|
||||
Type: "article", ID: strconv.Itoa(i), Title: strconv.Itoa(i),
|
||||
InputMessageContent: &telego.InputTextMessageContent{MessageText: "cl:x" + strconv.Itoa(i)},
|
||||
})
|
||||
}
|
||||
|
||||
first, next := pageInlineResults(all, "")
|
||||
if len(first) != maxInlineResults {
|
||||
t.Fatalf("first page = %d results, want %d", len(first), maxInlineResults)
|
||||
}
|
||||
if next != "50" {
|
||||
t.Fatalf("first page cursor = %q, want %q", next, "50")
|
||||
}
|
||||
second, next2 := pageInlineResults(all, next)
|
||||
if len(second) != 50 {
|
||||
t.Fatalf("second page = %d results, want 50", len(second))
|
||||
}
|
||||
if next2 != "100" {
|
||||
t.Fatalf("second page cursor = %q, want %q", next2, "100")
|
||||
}
|
||||
last, next3 := pageInlineResults(all, next2)
|
||||
if len(last) != 20 {
|
||||
t.Fatalf("last page = %d results, want 20", len(last))
|
||||
}
|
||||
if next3 != "" {
|
||||
t.Fatalf("last page cursor = %q, want empty", next3)
|
||||
}
|
||||
if _, beyond := pageInlineResults(all, "500"); beyond != "" {
|
||||
t.Fatalf("past-the-end cursor = %q, want empty", beyond)
|
||||
}
|
||||
if got, _ := pageInlineResults(all, "not-a-number"); len(got) != maxInlineResults {
|
||||
t.Fatalf("garbage cursor returned %d results, want a first page", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A short list must not grow a cursor, or Telegram keeps paging an exhausted list.
|
||||
func TestShortListHasNoCursor(t *testing.T) {
|
||||
list := []telego.InlineQueryResult{&telego.InlineQueryResultArticle{Type: "article", ID: "a"}}
|
||||
page, next := pageInlineResults(list, "")
|
||||
if len(page) != 1 || next != "" {
|
||||
t.Fatalf("page = %d results, cursor = %q; want 1 and empty", len(page), next)
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram caps a result id at 64 bytes; one long email would sink the answer.
|
||||
func TestLongMarkerStaysInsideTheResultIDLimit(t *testing.T) {
|
||||
long := "cl:" + strings.Repeat("a", 200) + "@example.com"
|
||||
id := markerResultID(long)
|
||||
if len(id) > 64 {
|
||||
t.Fatalf("result id is %d bytes, want at most 64", len(id))
|
||||
}
|
||||
if id == markerResultID("cl:"+strings.Repeat("a", 201)+"@example.com") {
|
||||
t.Fatal("two different markers hashed to the same id")
|
||||
}
|
||||
if got := markerResultID("cl:user@x"); got != "cl:user@x" {
|
||||
t.Fatalf("short marker = %q, want it unchanged", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// Bridges for the screens still served by the legacy views. Each one is a
|
||||
// screen now, so the router's callback cases only decide which view to draw.
|
||||
|
||||
// clearScreen drops the tracked screen and the user's own message: the pilot
|
||||
// tap that closes the reply keyboard must leave nothing behind.
|
||||
func (t *Tgbot) clearScreen(message telego.Message) {
|
||||
t.deleteIncoming(&message)
|
||||
if sc, ok := t.screens().get(message.Chat.ID); ok && sc.msgID != 0 {
|
||||
t.deleteOwnMessage(message.Chat.ID, sc.msgID)
|
||||
}
|
||||
t.screens().clear(message.Chat.ID)
|
||||
}
|
||||
|
||||
// hideMessage deletes the very message whose button was tapped. The id comes
|
||||
// from the callback, so nothing has to be remembered to make it work.
|
||||
func (t *Tgbot) hideMessage(query *telego.CallbackQuery) {
|
||||
if query.Message == nil {
|
||||
return
|
||||
}
|
||||
chat := query.Message.GetChat()
|
||||
t.deleteOwnMessage(chat.ID, query.Message.GetMessageID())
|
||||
// A hidden notice must not stay the chat's screen either, and the next event
|
||||
// of its kind has to start a fresh card rather than edit a deleted one.
|
||||
if sc, ok := t.screens().get(chat.ID); ok && sc.msgID == query.Message.GetMessageID() {
|
||||
t.screens().clear(chat.ID)
|
||||
}
|
||||
t.dropNoticeFor(chat.ID, query.Message.GetMessageID())
|
||||
t.answerSilent(query.ID)
|
||||
}
|
||||
|
||||
// turnPage moves the stored screen one body-page back or forward.
|
||||
func (t *Tgbot) turnPage(query *telego.CallbackQuery, delta int) {
|
||||
chatID := query.Message.GetChat().ID
|
||||
sc, ok := t.screens().get(chatID)
|
||||
if !ok {
|
||||
t.answerSilent(query.ID)
|
||||
return
|
||||
}
|
||||
t.showPage(chatID, sc.page+delta)
|
||||
t.answerSilent(query.ID)
|
||||
}
|
||||
|
||||
// resetAllConfirm is the confirmation screen for the panel-wide traffic reset.
|
||||
func (t *Tgbot) resetAllConfirm() *screen {
|
||||
// The forward action and the way out must be different rows: sharing one
|
||||
// row made "Cancel" read as part of the confirmation.
|
||||
return t.newScreen("main", t.I18nBot("tgbot.messages.AreYouSure"),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.confirmResetTraffic", "reset_all_traffics_c")),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.back", cbHome)),
|
||||
)
|
||||
}
|
||||
|
||||
// screenInbounds summarises the inbounds; the browsable, searchable list is the
|
||||
// inline query, so the screen carries only the summary and the way back.
|
||||
func (t *Tgbot) screenInbounds(chatID, userID int64) {
|
||||
_ = userID
|
||||
body := t.getInboundUsages()
|
||||
body += t.I18nBot("tgbot.messages.inlineHint")
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
t.launchRow(scopeQuery(inlineScopeInbounds, ""), "tgbot.buttons.searchInbounds"),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("inbounds", body, rows...))
|
||||
}
|
||||
|
||||
// browseInboundsScreen is the pick-an-inbound step: the list itself is the
|
||||
// inline browser, so the screen carries the hint and the search launcher.
|
||||
func (t *Tgbot) browseInboundsScreen(chatID, userID int64) {
|
||||
_ = userID
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
t.launchRow(scopeQuery(inlineScopeInbounds, ""), "tgbot.buttons.searchInbounds"),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("inbounds", t.I18nBot("tgbot.answers.chooseInbound"), rows...))
|
||||
}
|
||||
|
||||
// screenAddClientStart opens the add-client wizard on a fresh draft: filling the
|
||||
// draft is the first step, and inbounds are picked on "➕ Attach inbound".
|
||||
func (t *Tgbot) screenAddClientStart(chatID, userID int64) {
|
||||
actor := chatUser{chatID: chatID, userID: userID}
|
||||
addClientDrafts.reset(actor)
|
||||
draft := addClientDrafts.forActor(actor)
|
||||
draft.Lock()
|
||||
defer draft.Unlock()
|
||||
t.resetDraft(draft)
|
||||
t.addClient(chatID, draft, t.BuildClientDraftMessage(draft))
|
||||
}
|
||||
|
||||
// restartXrayFromScreen restarts Xray and reports the outcome as a toast, so
|
||||
// the menu screen itself stays put.
|
||||
func (t *Tgbot) restartXrayFromScreen(query *telego.CallbackQuery) {
|
||||
if !t.xrayService.IsXrayRunning() {
|
||||
t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.xrayNotRunning"))
|
||||
return
|
||||
}
|
||||
if err := t.xrayService.RestartXray(true); err != nil {
|
||||
logger.Warning("Xray restart failed:", err)
|
||||
t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error()))
|
||||
return
|
||||
}
|
||||
t.sendCallbackAnswerTgBot(query.ID, t.I18nBot("tgbot.commands.restartSuccess"))
|
||||
}
|
||||
|
||||
// searchClientScreen renders the client card as a screen; every per-email
|
||||
// callback that used to post a message lands here.
|
||||
func (t *Tgbot) searchClientScreen(chatID int64, email string) {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil || traffic == nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
body := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
||||
t.renderScreen(chatID, t.newScreen("client", body, t.clientRows(email)...))
|
||||
}
|
||||
|
||||
// clientRows is the client card's keyboard, in one place so every entry point
|
||||
// draws an identical card.
|
||||
func (t *Tgbot) clientRows(email string) [][]telego.InlineKeyboardButton {
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", t.encodeQuery("client_refresh "+email))),
|
||||
tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.resetTraffic", t.encodeQuery("reset_traffic "+email)),
|
||||
t.btn("tgbot.buttons.limitTraffic", t.encodeQuery("limit_traffic "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.resetExpire", t.encodeQuery("reset_exp "+email))),
|
||||
tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.ipLog", t.encodeQuery("ip_log "+email)),
|
||||
t.btn("tgbot.buttons.ipLimit", t.encodeQuery("ip_limit "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.setTGUser", t.encodeQuery("tg_user "+email)),
|
||||
t.btn("tgbot.buttons.inviteLink", t.encodeQuery("client_invite_link "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.toggle", t.encodeQuery("toggle_enable "+email))),
|
||||
t.backRow(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// Every screen's keyboard is built here, one builder per screen: keyboards built
|
||||
// inside the router's callback cases could be edited without their screen noticing.
|
||||
|
||||
const (
|
||||
cbHome = "home"
|
||||
cbActions = "act"
|
||||
cbHide = "hide"
|
||||
cbPageNext = "pg:next"
|
||||
cbPagePrev = "pg:prev"
|
||||
cbPageNone = "pg:none"
|
||||
cbServer = "srv"
|
||||
cbInbounds = "inb"
|
||||
cbClients = "cli"
|
||||
cbOnlines = "onl"
|
||||
cbDeplete = "dep"
|
||||
cbReport = "rep"
|
||||
cbBackup = "bkp"
|
||||
cbBanLogs = "ban"
|
||||
cbAddClient = "addc"
|
||||
cbCatServer = "cat:server"
|
||||
cbCatClients = "cat:clients"
|
||||
cbCatTraffic = "cat:traffic"
|
||||
cbBroadcast = "bc"
|
||||
cbCatMaintenance = "cat:maintenance"
|
||||
cbResetAll = "rst_all"
|
||||
cbRestartXry = "xray_restart"
|
||||
)
|
||||
|
||||
// homeRows is the admin's menu: four categories and a refresh, one row per
|
||||
// subject, so every subject is one tap deeper and nothing is more than two away.
|
||||
func (t *Tgbot) homeRows() [][]telego.InlineKeyboardButton {
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.catServer", cbCatServer),
|
||||
t.btn("tgbot.buttons.catClients", cbCatClients),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.catTraffic", cbCatTraffic),
|
||||
t.btn("tgbot.buttons.catMaintenance", cbCatMaintenance),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.refresh", cbHome),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// categoryRows is one menu subject's own rows.
|
||||
func (t *Tgbot) categoryRows(category string) [][]telego.InlineKeyboardButton {
|
||||
home := t.backRow()
|
||||
switch category {
|
||||
case "server":
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.serverUsage", cbServer)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.getInbounds", cbInbounds)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.onlines", cbOnlines)),
|
||||
home,
|
||||
}
|
||||
case "clients":
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.allClients", cbClients)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.addClient", cbAddClient)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.broadcast", cbBroadcast)),
|
||||
home,
|
||||
}
|
||||
case "traffic":
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.SortedTrafficUsageReport", cbReport)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.depleteSoon", cbDeplete)),
|
||||
home,
|
||||
}
|
||||
case "maintenance":
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.dbBackup", cbBackup)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.getBanLogs", cbBanLogs)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.restartXray", cbRestartXry)),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.ResetAllTraffics", cbResetAll)),
|
||||
home,
|
||||
}
|
||||
}
|
||||
return [][]telego.InlineKeyboardButton{home}
|
||||
}
|
||||
|
||||
// screenCategory draws one menu subject. The hint line names the subject, so a
|
||||
// user who arrived through a notification can see where they are.
|
||||
func (t *Tgbot) screenCategory(chatID int64, category string) {
|
||||
body := t.I18nBot("tgbot.messages.category_" + category)
|
||||
rows := [][]telego.InlineKeyboardButton{}
|
||||
rows = append(rows, t.categoryRows(category)...)
|
||||
t.renderScreen(chatID, t.newScreen("main", body, rows...))
|
||||
}
|
||||
|
||||
// btn is where a localized label becomes a callback button; btn2 is btn with a
|
||||
// caller-supplied label, since a client's email names its subscription best.
|
||||
func (t *Tgbot) btn2(label, data string) telego.InlineKeyboardButton {
|
||||
return tu.InlineKeyboardButton(label).WithCallbackData(data)
|
||||
}
|
||||
|
||||
func (t *Tgbot) btn(labelKey, data string) telego.InlineKeyboardButton {
|
||||
return tu.InlineKeyboardButton(t.I18nBot(labelKey)).WithCallbackData(data)
|
||||
}
|
||||
|
||||
// backRow is the single "up" control a non-root screen carries.
|
||||
func (t *Tgbot) backRow() []telego.InlineKeyboardButton {
|
||||
return tu.InlineKeyboardRow(t.btn("tgbot.buttons.back", cbHome))
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// Views for the read-only screens. Each one is a pure "read state, return a
|
||||
// screen" function; the router only decides which view to show.
|
||||
|
||||
// screenHome is the admin's landing screen: the numbers you open the bot for,
|
||||
// then the menu, with the bot's avatar until the panel can host its own artwork.
|
||||
func (t *Tgbot) screenHome(chatID, userID int64) {
|
||||
if level := t.levelOf(userID); level != levelAdmin {
|
||||
t.screenClientHome(chatID, userID)
|
||||
return
|
||||
}
|
||||
body := t.homeSummary()
|
||||
t.renderScreen(chatID, t.newScreen("main", body, t.homeRows()...))
|
||||
}
|
||||
|
||||
// screenClientHome is a client's own entry point: its usage, then links and QR.
|
||||
// /start once gave clients the ADMIN menu (panel ops, every client's email).
|
||||
func (t *Tgbot) screenClientHome(chatID, userID int64) {
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(userID)
|
||||
if err != nil || len(traffics) == 0 {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(userID, 10)))
|
||||
return
|
||||
}
|
||||
var body strings.Builder
|
||||
for _, traffic := range traffics {
|
||||
body.WriteString(t.clientInfoMsg(traffic, true, true, true, true, true, true))
|
||||
body.WriteString("\r\n")
|
||||
}
|
||||
// One subscription needs no choice (links and QR are right here); several are
|
||||
// one row each. Either way this is the client's top level, so no back button.
|
||||
var rows [][]telego.InlineKeyboardButton
|
||||
if len(traffics) == 1 {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
t.btn("tgbot.buttons.links", "client_sub_links"),
|
||||
t.btn("tgbot.buttons.qrCode", "client_qr_links"),
|
||||
))
|
||||
} else {
|
||||
for _, traffic := range traffics {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
t.btn2(traffic.Email, t.encodeQuery("client_sub_links "+traffic.Email)),
|
||||
))
|
||||
}
|
||||
}
|
||||
t.renderScreen(chatID, t.rootScreen("main", body.String(), rows...))
|
||||
}
|
||||
|
||||
// homeSummary is the panel overview line used by the menu and the report, so
|
||||
// the two can never disagree about the host or the version.
|
||||
func (t *Tgbot) homeSummary() string {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to load inbounds for the menu:", err)
|
||||
}
|
||||
clients := 0
|
||||
online := 0
|
||||
traffic := int64(0)
|
||||
for _, ib := range inbounds {
|
||||
clients += len(ib.ClientStats)
|
||||
traffic += ib.Up + ib.Down
|
||||
}
|
||||
if process := xrayProcessOrNil(); process != nil {
|
||||
online = len(process.GetOnlineClients())
|
||||
}
|
||||
body := ""
|
||||
body += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
body += t.I18nBot("tgbot.messages.version", "Version=="+panelVersion())
|
||||
body += t.I18nBot("tgbot.messages.inboundsCount", "Count=="+strconv.Itoa(len(inbounds)))
|
||||
body += t.I18nBot("tgbot.messages.clientsCount", "Count=="+strconv.Itoa(clients))
|
||||
body += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(online))
|
||||
body += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(traffic),
|
||||
"Upload=="+common.FormatTraffic(traffic), "Download=="+common.FormatTraffic(0))
|
||||
return body
|
||||
}
|
||||
|
||||
// screenServer renders the server-usage screen.
|
||||
func (t *Tgbot) screenServer(chatID int64) {
|
||||
body := strings.TrimRight(t.prepareServerUsageInfo(), "\r\n")
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbServer)),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("status", body, rows...))
|
||||
}
|
||||
|
||||
// screenOnlines lists who is connected right now; client names are plain text
|
||||
// (the inline browser takes over the per-client pick next).
|
||||
func (t *Tgbot) screenOnlines(chatID int64) {
|
||||
process := xrayProcessOrNil()
|
||||
if process == nil {
|
||||
t.renderScreen(chatID, t.newScreen("onlines", t.I18nBot("tgbot.wentWrong"),
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)), t.backRow()))
|
||||
return
|
||||
}
|
||||
onlines := process.GetOnlineClients()
|
||||
body := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+strconv.Itoa(len(onlines)))
|
||||
for _, online := range onlines {
|
||||
label := online
|
||||
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
|
||||
label = online + " · " + inbound.Remark
|
||||
}
|
||||
body += "• " + html.EscapeString(label) + "\n"
|
||||
}
|
||||
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbOnlines)),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("onlines", body, rows...))
|
||||
}
|
||||
|
||||
// screenDeplete renders the "running out" screen; the scheduled report and the
|
||||
// button share this text, so a client is never listed twice with a different story.
|
||||
func (t *Tgbot) screenDeplete(chatID int64) {
|
||||
body := t.depleteReport()
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbDeplete)),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("deplete", body, rows...))
|
||||
}
|
||||
|
||||
// depleteReport builds the exhausted/disabled summary as plain text.
|
||||
func (t *Tgbot) depleteReport() string {
|
||||
trDiff, exDiff := t.depletionThresholds()
|
||||
now := time.Now().Unix() * 1000
|
||||
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to load inbounds for the depletion report:", err)
|
||||
}
|
||||
exhaustedInbounds, disabledInbounds := 0, 0
|
||||
exhaustedClients, disabledClients := 0, 0
|
||||
seen := map[string]bool{}
|
||||
var clientLines strings.Builder
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if !inbound.Enable {
|
||||
disabledInbounds++
|
||||
continue
|
||||
}
|
||||
if (inbound.ExpiryTime > 0 && inbound.ExpiryTime-now < exDiff) ||
|
||||
(inbound.Total > 0 && inbound.Total-(inbound.Up+inbound.Down) < trDiff) {
|
||||
exhaustedInbounds++
|
||||
}
|
||||
for _, client := range inbound.ClientStats {
|
||||
if seen[client.Email] {
|
||||
continue
|
||||
}
|
||||
seen[client.Email] = true
|
||||
if !client.Enable {
|
||||
disabledClients++
|
||||
continue
|
||||
}
|
||||
if (client.ExpiryTime > 0 && client.ExpiryTime-now < exDiff) ||
|
||||
(client.Total > 0 && client.Total-(client.Up+client.Down) < trDiff) {
|
||||
exhaustedClients++
|
||||
clientLines.WriteString(t.clientInfoMsg(&client, true, false, false, true, true, false))
|
||||
clientLines.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body := ""
|
||||
body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
|
||||
body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledInbounds))
|
||||
body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedInbounds))
|
||||
body += "\n"
|
||||
body += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
|
||||
body += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(disabledClients))
|
||||
body += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedClients))
|
||||
if clientLines.Len() > 0 {
|
||||
body += "\n" + html.EscapeString(clientLines.String())
|
||||
}
|
||||
body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
return body
|
||||
}
|
||||
|
||||
// depletionThresholds reads the two "running out" windows from the settings.
|
||||
func (t *Tgbot) depletionThresholds() (traffic, expire int64) {
|
||||
if v, err := t.settingService.GetTrafficDiff(); err == nil && v > 0 {
|
||||
traffic = int64(v) * 1073741824
|
||||
}
|
||||
if v, err := t.settingService.GetExpireDiff(); err == nil && v > 0 {
|
||||
expire = int64(v) * 86400000
|
||||
}
|
||||
return traffic, expire
|
||||
}
|
||||
|
||||
// screenReport renders the sorted traffic-usage report. The screen pages it,
|
||||
// so a large panel gets one message with page controls, not a message flood.
|
||||
func (t *Tgbot) screenReport(chatID int64) {
|
||||
emails, err := t.inboundService.GetAllEmails()
|
||||
if err != nil {
|
||||
t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow()))
|
||||
return
|
||||
}
|
||||
validEmails, missingEmails, err := t.inboundService.FilterAndSortClientEmails(emails)
|
||||
if err != nil {
|
||||
t.renderScreen(chatID, t.newScreen("reports", t.I18nBot("tgbot.answers.errorOperation"), t.backRow()))
|
||||
return
|
||||
}
|
||||
|
||||
var report strings.Builder
|
||||
for _, email := range validEmails {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil || traffic == nil {
|
||||
fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult"))
|
||||
continue
|
||||
}
|
||||
report.WriteString(t.clientInfoMsg(traffic, false, false, false, false, true, false))
|
||||
report.WriteString("\n")
|
||||
}
|
||||
for _, email := range missingEmails {
|
||||
fmt.Fprintf(&report, "📧 %s\n%s\n", html.EscapeString(email), t.I18nBot("tgbot.noResult"))
|
||||
}
|
||||
rows := [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbReport)),
|
||||
t.backRow(),
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("reports", html.EscapeString(report.String()), rows...))
|
||||
}
|
||||
|
||||
// screenCommands explains the classic commands; kept because muscle memory is
|
||||
// real, but the menu no longer advertises them.
|
||||
func (t *Tgbot) screenCommands(chatID int64, isAdmin bool) {
|
||||
key := "tgbot.commands.helpClientCommands"
|
||||
if isAdmin {
|
||||
key = "tgbot.commands.helpAdminCommands"
|
||||
}
|
||||
t.renderScreen(chatID, t.newScreen("commands", t.I18nBot(key), t.backRow()))
|
||||
}
|
||||
|
||||
// sendBackupScreen delivers the backup and summary as ONE message (file plus
|
||||
// caption); a summary too long for a caption is sent after the file.
|
||||
func (t *Tgbot) sendBackupScreen(chatID int64) {
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup:", err)
|
||||
}
|
||||
summary := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
summary += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
if dbData == nil {
|
||||
t.sendNotice(chatID, summary)
|
||||
return
|
||||
}
|
||||
// The summary and the depletion list share the file's caption: one message
|
||||
// carries the backup and what it is for.
|
||||
body := summary + "\n\n" + t.depleteReport()
|
||||
caption := trimCaption(body, botCaptionLimit)
|
||||
if t.sendDocumentWithCaption(chatID, dbData, t.serverService.BackupFilename(""), caption) {
|
||||
// The generated config travels with the DB on both backup paths: an
|
||||
// operator restoring from the file would otherwise lose it silently.
|
||||
if config, err := os.ReadFile(xray.GetConfigPath()); err == nil {
|
||||
t.sendDocumentWithCaption(chatID, config, "config.json", "")
|
||||
}
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(body, caption)); rest != "" {
|
||||
// Rare: the summary outgrew a caption. The file already landed.
|
||||
t.sendNotice(chatID, rest)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The upload failed: the summary is still worth sending.
|
||||
t.sendNotice(chatID, summary)
|
||||
}
|
||||
|
||||
// screenBanLogs sends the ban log file as a notice, never as a screen.
|
||||
func (t *Tgbot) screenBanLogs(chatID int64) {
|
||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.sendNotice(chatID, output)
|
||||
t.sendBanLogsFile(chatID)
|
||||
}
|
||||
|
||||
// sendNotice posts a plain bot message that is explicitly not a screen: it
|
||||
// carries the hide button, and it never becomes the chat's tracked screen.
|
||||
func (t *Tgbot) sendNotice(chatID int64, text string) {
|
||||
t.sendNoticeReturningID(chatID, text)
|
||||
}
|
||||
|
||||
// sendNoticeReturningID posts one dismissable message and hands back its id, so
|
||||
// the caller can replace it later instead of leaving copies behind.
|
||||
func (t *Tgbot) sendNoticeReturningID(chatID int64, text string) int {
|
||||
if liveBot() == nil || text == "" {
|
||||
return 0
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
sent, err := liveBot().SendMessage(ctx, &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), Text: text, ParseMode: "HTML", ReplyMarkup: t.hideButton(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send a notice:", err)
|
||||
return 0
|
||||
}
|
||||
return sent.MessageID
|
||||
}
|
||||
|
||||
// sendBanLogsFile delivers the banned-IP log as a notice with a hide button.
|
||||
func (t *Tgbot) sendBanLogsFile(chatID int64) {
|
||||
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
|
||||
if err != nil {
|
||||
t.sendNotice(chatID, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
_, err = liveBot().SendDocument(ctx, &telego.SendDocumentParams{
|
||||
ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID),
|
||||
Document: tu.File(file),
|
||||
ReplyMarkup: t.hideButton(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to upload the ban log:", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// Screen art: the bot's avatar serves every screen's picture, and a panel without
|
||||
// one gets a generated black tile, so a screen never fails for want of a picture.
|
||||
|
||||
var (
|
||||
screenArtMu sync.RWMutex
|
||||
screenArtIDs = map[string]string{}
|
||||
// artOverrides is artwork the panel set for one screen kind. It outranks
|
||||
// the bot's avatar, so adopting an avatar never silently replaces it.
|
||||
artOverrides = map[string]string{}
|
||||
// artFromAvatar records whether the cached ids came from the bot's avatar: a
|
||||
// tile cached from an upload must never win over an avatar set afterwards.
|
||||
artFromAvatar bool
|
||||
artProbedAt time.Time
|
||||
// artProbeRunning keeps a burst of renders from starting a burst of probes.
|
||||
artProbeRunning atomic.Bool
|
||||
|
||||
blackTileOnce sync.Once
|
||||
blackTileBytes []byte
|
||||
)
|
||||
|
||||
// Probes are cheap but not free: a known avatar is trusted for a while, a missing
|
||||
// one retried sooner, so an uploaded picture shows without a restart.
|
||||
const (
|
||||
artAvatarTTL = 10 * time.Minute
|
||||
artRetryTTL = time.Minute
|
||||
)
|
||||
|
||||
// resolveScreenArt caches the bot avatar's file_id for every screen kind.
|
||||
// Telegram serves one photo per bot, so all kinds share it.
|
||||
func (t *Tgbot) resolveScreenArt() {
|
||||
// One read: the panel can swap the bot under us (login/logout), and reading
|
||||
// the global per call is a nil-pointer crash in this background goroutine.
|
||||
b := liveBot()
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
me, err := b.GetMe(ctx)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to read the bot identity for screen art:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use getUserProfilePhotos, not getChat: getChat's ChatPhoto file_id is
|
||||
// refused in sendPhoto ("can't use file of type ChatPhoto as Photo").
|
||||
photos, err := b.GetUserProfilePhotos(ctx, &telego.GetUserProfilePhotosParams{
|
||||
UserID: me.ID, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("Failed to read the bot's profile photos for screen art:", err)
|
||||
return
|
||||
}
|
||||
|
||||
screenArtMu.Lock()
|
||||
defer screenArtMu.Unlock()
|
||||
artProbedAt = time.Now()
|
||||
if len(photos.Photos) == 0 || len(photos.Photos[0]) == 0 {
|
||||
// Remember the miss so the next render does not probe again, but keep
|
||||
// any tile already cached: an avatar-less bot still shows a picture.
|
||||
artFromAvatar = false
|
||||
logger.Debug("Bot has no avatar; screens will use the black fallback")
|
||||
return
|
||||
}
|
||||
// The last entry is the largest size Telegram keeps.
|
||||
sizes := photos.Photos[0]
|
||||
fileID := sizes[len(sizes)-1].FileID
|
||||
screenArtIDs = map[string]string{}
|
||||
for _, kind := range screenKinds {
|
||||
screenArtIDs[kind] = fileID
|
||||
}
|
||||
artFromAvatar = true
|
||||
}
|
||||
|
||||
// ensureScreenArt refreshes stale cached art off the render path, at most one
|
||||
// probe in flight, so a just-uploaded picture shows without a restart.
|
||||
func (t *Tgbot) ensureScreenArt() {
|
||||
screenArtMu.RLock()
|
||||
fromAvatar, at := artFromAvatar, artProbedAt
|
||||
screenArtMu.RUnlock()
|
||||
|
||||
ttl := artRetryTTL
|
||||
if fromAvatar {
|
||||
ttl = artAvatarTTL
|
||||
}
|
||||
if time.Since(at) <= ttl {
|
||||
return
|
||||
}
|
||||
if !artProbeRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer artProbeRunning.Store(false)
|
||||
t.resolveScreenArt()
|
||||
}()
|
||||
}
|
||||
|
||||
// artFileID returns the cached file_id for a screen kind. A fallback tile cached
|
||||
// for one kind must not answer for another, so the per-kind entry is authoritative.
|
||||
func artFileID(kind string) (string, bool) {
|
||||
screenArtMu.RLock()
|
||||
defer screenArtMu.RUnlock()
|
||||
if id, ok := artOverrides[kind]; ok && id != "" {
|
||||
return id, true
|
||||
}
|
||||
id, ok := screenArtIDs[kind]
|
||||
return id, ok && id != ""
|
||||
}
|
||||
|
||||
// artUpload is the last-resort picture: the generated black tile.
|
||||
func artUpload(kind string) (telego.InputFile, bool) {
|
||||
data := blackTile()
|
||||
return tu.FileFromBytes(data, "screen.png"), data != nil
|
||||
}
|
||||
|
||||
// blackTile builds the 512x512 black tile once.
|
||||
func blackTile() []byte {
|
||||
blackTileOnce.Do(func() {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 512, 512))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{R: 13, G: 15, B: 20, A: 255}}, image.Point{}, draw.Src)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
logger.Warning("Failed to encode the fallback screen art:", err)
|
||||
return
|
||||
}
|
||||
blackTileBytes = buf.Bytes()
|
||||
})
|
||||
return blackTileBytes
|
||||
}
|
||||
|
||||
// cacheArtFileID remembers the file_id Telegram returned for an uploaded tile,
|
||||
// so that screen edits in place. It does NOT mark the art as the avatar.
|
||||
func cacheArtFileID(kind, fileID string) {
|
||||
if kind == "" || fileID == "" {
|
||||
return
|
||||
}
|
||||
screenArtMu.Lock()
|
||||
if !artFromAvatar {
|
||||
screenArtIDs[kind] = fileID
|
||||
}
|
||||
screenArtMu.Unlock()
|
||||
}
|
||||
|
||||
// resetScreenArt drops the cached file_ids; tests use it between mock servers.
|
||||
func resetScreenArt() {
|
||||
screenArtMu.Lock()
|
||||
screenArtIDs = map[string]string{}
|
||||
artOverrides = map[string]string{}
|
||||
artFromAvatar = false
|
||||
artProbedAt = time.Time{}
|
||||
screenArtMu.Unlock()
|
||||
}
|
||||
|
||||
// screenKinds is the catalogue of screen pictures: every kind resolves to the
|
||||
// same avatar, but the names keep each screen's intent visible in the code.
|
||||
var screenKinds = []string{
|
||||
"main", "status", "inbounds", "clients", "client", "links", "qr",
|
||||
"wizard", "reports", "backup", "onlines", "deplete", "commands", "broadcast",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,9 @@ import (
|
||||
|
||||
var (
|
||||
bot *telego.Bot
|
||||
// botMu guards bot: EnsureBot may replace it while a background goroutine
|
||||
// (the screen artwork warm-up) is still using the previous liveBot().
|
||||
botMu sync.RWMutex
|
||||
|
||||
// botCancel stores the function to cancel the context, stopping Long Polling gracefully.
|
||||
botCancel context.CancelFunc
|
||||
@@ -66,6 +69,13 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// liveBot is the current connection, or nil before login and after logout.
|
||||
func liveBot() *telego.Bot {
|
||||
botMu.RLock()
|
||||
defer botMu.RUnlock()
|
||||
return bot
|
||||
}
|
||||
|
||||
// clientDraft is one chat's add-client wizard state. Per-protocol secrets are
|
||||
// filled per-inbound on submit, so only the universal fields live here.
|
||||
type clientDraft struct {
|
||||
@@ -346,13 +356,16 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
}
|
||||
|
||||
// Create new Telegram bot instance
|
||||
botMu.Lock()
|
||||
bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer)
|
||||
botMu.Unlock()
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize Telegram bot API:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
t.trySetBotCommands(bot)
|
||||
t.resolveScreenArt()
|
||||
t.trySetBotCommands(liveBot())
|
||||
|
||||
// Start receiving Telegram bot messages
|
||||
tgBotMutex.Lock()
|
||||
@@ -373,17 +386,11 @@ func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Only /start is advertised: the rest of the actions live on buttons, and
|
||||
// the old list merely duplicated the menu. The commands still work.
|
||||
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
|
||||
Commands: []telego.BotCommand{
|
||||
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
|
||||
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
|
||||
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
|
||||
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
|
||||
{Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")},
|
||||
{Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")},
|
||||
{Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")},
|
||||
{Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")},
|
||||
{Command: "broadcast", Description: t.I18nBot("tgbot.commands.broadcastDesc")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -551,7 +558,7 @@ func inAdminTopic(document *telego.SendDocumentParams) *telego.SendDocumentParam
|
||||
return document
|
||||
}
|
||||
|
||||
// SetHostname sets the hostname for the bot.
|
||||
// SetHostname sets the hostname for the liveBot().
|
||||
func (t *Tgbot) SetHostname() {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
@@ -594,7 +601,7 @@ func StopBot() {
|
||||
}
|
||||
|
||||
if cancel != nil {
|
||||
logger.Info("Sending cancellation signal to Telegram bot...")
|
||||
logger.Info("Sending cancellation signal to Telegram liveBot()...")
|
||||
// Cancels the context passed to UpdatesViaLongPolling; this closes updates channel
|
||||
// and lets botHandler.Start() exit cleanly.
|
||||
cancel()
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
)
|
||||
|
||||
func seedPickerInbounds(t *testing.T, protocols ...model.Protocol) {
|
||||
t.Helper()
|
||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
||||
for i, protocol := range protocols {
|
||||
port := 20000 + i
|
||||
ib := &model.Inbound{Remark: string(protocol), Enable: true, Port: port, Protocol: protocol, Tag: fmt.Sprintf("inbound-%d", port), Settings: `{}`}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed %s inbound: %v", protocol, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pickerLabels(keyboard *telego.InlineKeyboardMarkup) []string {
|
||||
var labels []string
|
||||
for _, row := range keyboard.InlineKeyboard {
|
||||
for _, button := range row {
|
||||
labels = append(labels, button.Text)
|
||||
}
|
||||
}
|
||||
slices.Sort(labels)
|
||||
return labels
|
||||
}
|
||||
|
||||
// WireGuard and AmneziaWG clients get a generated keypair and address on Create,
|
||||
// so the wizard offers them; Mixed authenticates per inbound and has no clients.
|
||||
func TestAddClientPickerOffersWireGuardAndAmneziaWG(t *testing.T) {
|
||||
seedPickerInbounds(t, model.VLESS, model.WireGuard, model.AmneziaWG, model.Mixed)
|
||||
|
||||
keyboard, err := (&Tgbot{}).getInboundsAddClient()
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundsAddClient: %v", err)
|
||||
}
|
||||
want := []string{"amneziawg - ✅", "vless - ✅", "wireguard - ✅"}
|
||||
if got := pickerLabels(keyboard); !slices.Equal(got, want) {
|
||||
t.Fatalf("picker buttons = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty keyboard sent the admin a "choose inbound" prompt with nothing to tap.
|
||||
func TestAddClientPickerFailsWhenNoInboundTakesClients(t *testing.T) {
|
||||
draftLocalizer(t, &i18n.Message{ID: "tgbot.answers.getInboundsFailed", Other: "Failed to get inbounds."})
|
||||
seedPickerInbounds(t, model.Mixed, model.HTTP, model.Tunnel)
|
||||
|
||||
keyboard, err := (&Tgbot{}).getInboundsAddClient()
|
||||
if err == nil || err.Error() != "Failed to get inbounds." {
|
||||
t.Fatalf("getInboundsAddClient = (%v, %v), want the getInboundsFailed error", keyboard, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
// seedPickerInbounds inserts one enabled inbound per protocol.
|
||||
func seedPickerInbounds(t *testing.T, protocols ...model.Protocol) {
|
||||
t.Helper()
|
||||
for _, protocol := range protocols {
|
||||
tag := "pick-" + string(protocol)
|
||||
if err := database.GetDB().Create(&model.Inbound{
|
||||
UserId: 1, Tag: tag, Remark: string(protocol), Port: 8443,
|
||||
Protocol: protocol, Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed %s inbound: %v", protocol, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attachPickerRows builds the attach picker for a draft and flattens it.
|
||||
func attachPickerRows(t *testing.T, tb *Tgbot, draft *clientDraft) []telego.InlineKeyboardButton {
|
||||
t.Helper()
|
||||
picker, err := tb.getInboundsAttachPicker(draft)
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundsAttachPicker: %v", err)
|
||||
}
|
||||
var rows []telego.InlineKeyboardButton
|
||||
for _, row := range picker.InlineKeyboard {
|
||||
rows = append(rows, row...)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// WireGuard and AmneziaWG clients get a generated keypair and address on Create,
|
||||
// so the attach picker offers them; Mixed authenticates per inbound and has none.
|
||||
func TestAttachPickerOffersWireGuardAndAmneziaWG(t *testing.T) {
|
||||
tb, _ := newScreenTgbot(t, false)
|
||||
initReportDB(t)
|
||||
seedPickerInbounds(t, model.VLESS, model.WireGuard, model.AmneziaWG, model.Mixed)
|
||||
|
||||
draft := &clientDraft{}
|
||||
// The picker marks selection, so its labels carry the checkbox and protocol.
|
||||
rows := attachPickerRows(t, tb, draft)
|
||||
var labels []string
|
||||
for _, row := range rows {
|
||||
labels = append(labels, row.Text)
|
||||
}
|
||||
slices.Sort(labels)
|
||||
for _, want := range []string{"amneziawg", "vless", "wireguard"} {
|
||||
found := false
|
||||
for _, label := range labels {
|
||||
if strings.Contains(label, want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the attach picker does not offer %s: %q", want, labels)
|
||||
}
|
||||
}
|
||||
for _, label := range labels {
|
||||
if strings.Contains(label, "mixed") {
|
||||
t.Errorf("the attach picker offers Mixed, which has no clients: %q", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The wizard starts on a draft, not an inbound picker: the client is created first and attached after,
|
||||
// the bug that made "Add client" ask for an inbound and then offer existing clients.
|
||||
func TestAddClientStartsOnADraftWithNoInboundChosen(t *testing.T) {
|
||||
localizeWithRealBundle(t)
|
||||
tb, _ := newScreenTgbot(t, false)
|
||||
|
||||
tb.screenAddClientStart(777, 1)
|
||||
|
||||
sc, ok := tb.screens().get(777)
|
||||
if !ok {
|
||||
t.Fatal("the add-client flow drew no screen")
|
||||
}
|
||||
// The draft card is shown, and nothing is attached yet.
|
||||
if !strings.Contains(sc.pages[0], "newclient") && !strings.Contains(sc.pages[0], "Email") {
|
||||
t.Errorf("the first screen is not the draft card: %s", sc.pages[0])
|
||||
}
|
||||
if strings.Contains(sc.pages[0], "pickInbound") || strings.Contains(sc.pages[0], "Pick the inbound") {
|
||||
t.Errorf("the flow still opens on an inbound picker: %s", sc.pages[0])
|
||||
}
|
||||
markup, _ := json.Marshal(sc.markup)
|
||||
if !strings.Contains(string(markup), "add_client_attach_more") {
|
||||
t.Errorf("the draft offers no way to attach an inbound: %s", markup)
|
||||
}
|
||||
if strings.Contains(string(markup), "add_client_ch_default_email") == false {
|
||||
t.Errorf("the draft offers no way to set the client's fields: %s", markup)
|
||||
}
|
||||
}
|
||||
|
||||
// Inbounds are chosen from the draft's own button while filling the card, and submitting without one is
|
||||
// refused instead of creating a client attached to nothing.
|
||||
func TestDraftCanBeFilledBeforeAnyInboundIsAttached(t *testing.T) {
|
||||
localizeWithRealBundle(t)
|
||||
tb, _ := newScreenTgbot(t, false)
|
||||
draft := &clientDraft{email: "draft-only"}
|
||||
|
||||
rows := tb.getCommonClientButtons(draft)
|
||||
markup, _ := json.Marshal(rows)
|
||||
for _, want := range []string{
|
||||
"add_client_ch_default_email", "add_client_ch_default_comment",
|
||||
"add_client_ch_default_traffic", "add_client_ch_default_exp",
|
||||
"add_client_ch_default_ip_limit", "add_client_ch_default_tg_id",
|
||||
"add_client_attach_more", "add_client_submit_enable", "add_client_cancel",
|
||||
} {
|
||||
if !strings.Contains(string(markup), want) {
|
||||
t.Errorf("the draft card lacks %s", want)
|
||||
}
|
||||
}
|
||||
// The attach button says how many are attached, and it is localized.
|
||||
for _, row := range rows {
|
||||
for _, button := range row {
|
||||
if button.CallbackData != "add_client_attach_more" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(button.Text) == "" || strings.HasPrefix(button.Text, "tgbot.") {
|
||||
t.Errorf("the attach button has no localized label: %q", button.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tb.SubmitAddClient(draft); err == nil {
|
||||
t.Error("submitting with no inbound attached created a client anyway")
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const (
|
||||
)
|
||||
|
||||
// broadcastDraft references the admin's original message: copyMessage relays
|
||||
// any message type 1:1 on behalf of the bot. An album lists its messages.
|
||||
// any message type 1:1 on behalf of the liveBot(). An album lists its messages.
|
||||
type broadcastDraft struct {
|
||||
FromChatID int64
|
||||
MessageIDs []int
|
||||
@@ -53,6 +53,7 @@ type broadcastResult struct {
|
||||
// broadcastRunner tracks the single in-flight broadcast: where to report
|
||||
// progress and whether the admin asked to stop it.
|
||||
type broadcastRunner struct {
|
||||
actor chatUser
|
||||
chatID int64
|
||||
messageID int
|
||||
cancel atomic.Bool
|
||||
@@ -77,15 +78,21 @@ func (r *broadcastRunner) getResult() broadcastResult {
|
||||
// album group still arriving, and the token binding the preview to its card.
|
||||
type broadcastCompose struct {
|
||||
messageIDs []int
|
||||
groupID string
|
||||
token string
|
||||
timer *time.Timer
|
||||
// inputMessageIDs are the admin's own messages: the SOURCE of the copy, never
|
||||
// seen by recipients, so they are removed once the broadcast is confirmed/dropped.
|
||||
inputMessageIDs []int
|
||||
groupID string
|
||||
token string
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
var (
|
||||
broadcastMu sync.Mutex
|
||||
broadcastComposes = make(map[chatUser]*broadcastCompose)
|
||||
broadcastActive *broadcastRunner
|
||||
// broadcastPrompts is the "send me the message" card per admin, remembered
|
||||
// only so that leaving the flow takes it out of the chat.
|
||||
broadcastPrompts = make(map[chatUser]int)
|
||||
broadcastActive *broadcastRunner
|
||||
)
|
||||
|
||||
// broadcastAlbumDebounce waits out Telegram's stream of one media group: an
|
||||
@@ -111,6 +118,7 @@ func broadcastResetAll() {
|
||||
broadcastActive.cancel.Store(true)
|
||||
broadcastActive = nil
|
||||
}
|
||||
broadcastPrompts = make(map[chatUser]int)
|
||||
}
|
||||
|
||||
func broadcastDropCompose(actor chatUser) {
|
||||
@@ -122,6 +130,18 @@ func broadcastDropCompose(actor chatUser) {
|
||||
delete(broadcastComposes, actor)
|
||||
}
|
||||
|
||||
// broadcastComposeInputs returns the admin's own composing messages for the
|
||||
// pending draft, empty while an album is still being collected.
|
||||
func broadcastComposeInputs(actor chatUser) []int {
|
||||
broadcastMu.Lock()
|
||||
defer broadcastMu.Unlock()
|
||||
c := broadcastComposes[actor]
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]int(nil), c.inputMessageIDs...)
|
||||
}
|
||||
|
||||
// broadcastPendingDraft reports the ids awaiting an admin's confirmation;
|
||||
// ok is false while an album is still being collected.
|
||||
func broadcastPendingDraft(actor chatUser) ([]int, string, bool) {
|
||||
@@ -149,13 +169,13 @@ func broadcastTakePending(actor chatUser, token string) ([]int, bool) {
|
||||
|
||||
// broadcastRegisterRunner claims the single broadcast slot; nil means one is
|
||||
// already running.
|
||||
func broadcastRegisterRunner(chatID int64) *broadcastRunner {
|
||||
func broadcastRegisterRunner(actor chatUser) *broadcastRunner {
|
||||
broadcastMu.Lock()
|
||||
defer broadcastMu.Unlock()
|
||||
if broadcastActive != nil {
|
||||
return nil
|
||||
}
|
||||
broadcastActive = &broadcastRunner{chatID: chatID}
|
||||
broadcastActive = &broadcastRunner{actor: actor, chatID: actor.chatID}
|
||||
return broadcastActive
|
||||
}
|
||||
|
||||
@@ -175,7 +195,8 @@ func broadcastCurrentRunner() *broadcastRunner {
|
||||
return broadcastActive
|
||||
}
|
||||
|
||||
// broadcastSender delivers one draft to one chat; swapped out in tests.
|
||||
// broadcastSender delivers one draft to one chat and reports the ids it made;
|
||||
// swapped out in tests.
|
||||
var broadcastSender = deliverBroadcastCopy
|
||||
|
||||
// broadcastPause stands in for time.Sleep so tests don't wait real seconds.
|
||||
@@ -190,14 +211,48 @@ func (t *Tgbot) startBroadcast(actor chatUser) {
|
||||
return
|
||||
}
|
||||
broadcastDropCompose(actor)
|
||||
// A second prompt supersedes the first: twin "send me the message" cards in
|
||||
// one chat is the sort of litter this flow is supposed to avoid.
|
||||
t.clearBroadcastPrompt(actor)
|
||||
userStateMgr.set(actor, broadcastAwaitingText)
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard())
|
||||
id := t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastAskText"), t.broadcastCancelKeyboard())
|
||||
broadcastSetPrompt(actor, id)
|
||||
}
|
||||
|
||||
// broadcastSetPrompt remembers the "send me the message" card, so leaving the
|
||||
// flow cannot strand it in the chat.
|
||||
func broadcastSetPrompt(actor chatUser, messageID int) {
|
||||
if messageID == 0 {
|
||||
return
|
||||
}
|
||||
broadcastMu.Lock()
|
||||
broadcastPrompts[actor] = messageID
|
||||
broadcastMu.Unlock()
|
||||
}
|
||||
|
||||
// broadcastTakePrompt returns the pending prompt and forgets it.
|
||||
func broadcastTakePrompt(actor chatUser) int {
|
||||
broadcastMu.Lock()
|
||||
defer broadcastMu.Unlock()
|
||||
id := broadcastPrompts[actor]
|
||||
delete(broadcastPrompts, actor)
|
||||
return id
|
||||
}
|
||||
|
||||
// clearBroadcastPrompt removes the prompt from the chat, if one is pending.
|
||||
func (t *Tgbot) clearBroadcastPrompt(actor chatUser) {
|
||||
if id := broadcastTakePrompt(actor); id != 0 {
|
||||
t.deleteScreenMessage(actor.chatID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBroadcastInput references the message the admin sent and shows the
|
||||
// confirmation preview. The router hands over only the admin /broadcast awaits.
|
||||
func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) {
|
||||
logger.Debugf("broadcast: chat %d input (message_id=%d group=%q)", actor.chatID, message.MessageID, message.MediaGroupID)
|
||||
// The prompt has been answered: it is the admin's scratch space, not the
|
||||
// broadcast, so it does not stay behind the preview card.
|
||||
t.clearBroadcastPrompt(actor)
|
||||
if message.MediaGroupID == "" {
|
||||
broadcastDropCompose(actor)
|
||||
t.acceptBroadcastDraft(actor, []int{message.MessageID})
|
||||
@@ -210,17 +265,23 @@ func (t *Tgbot) handleBroadcastInput(message *telego.Message, actor chatUser) {
|
||||
// exactly what recipients will get — and shows the confirmation preview.
|
||||
func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) {
|
||||
chatId := actor.chatID
|
||||
if err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids}); err != nil {
|
||||
previewIDs, err := broadcastSender(chatId, broadcastDraft{FromChatID: chatId, MessageIDs: ids})
|
||||
if err != nil {
|
||||
broadcastDropCompose(actor)
|
||||
userStateMgr.clear(actor)
|
||||
logger.Warningf("broadcast: chat %d message %v cannot be copied: %v", chatId, ids, err)
|
||||
t.clearBroadcastDraft(actor, ids, nil)
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastNotCopyable"))
|
||||
return
|
||||
}
|
||||
recipients := t.collectBroadcastRecipients()
|
||||
token := t.randomLowerAndNum(12)
|
||||
broadcastMu.Lock()
|
||||
broadcastComposes[actor] = &broadcastCompose{messageIDs: ids, token: token}
|
||||
broadcastComposes[actor] = &broadcastCompose{
|
||||
messageIDs: ids,
|
||||
inputMessageIDs: ids,
|
||||
token: token,
|
||||
}
|
||||
broadcastMu.Unlock()
|
||||
userStateMgr.clear(actor)
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
@@ -228,6 +289,30 @@ func (t *Tgbot) acceptBroadcastDraft(actor chatUser, ids []int) {
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"),
|
||||
))
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.broadcastPreview", "Count=="+strconv.Itoa(len(recipients))), keyboard)
|
||||
// The preview is the copy recipients receive. It has served its purpose once
|
||||
// the admin has looked at it; leaving it behind produced duplicate copies.
|
||||
for _, id := range previewIDs {
|
||||
t.deleteOwnMessage(chatId, id)
|
||||
}
|
||||
}
|
||||
|
||||
// clearBroadcastDraft removes the admin's own composing messages — the input the
|
||||
// broadcast copies FROM and, if made, its preview — none of which is the broadcast.
|
||||
func (t *Tgbot) clearBroadcastDraft(actor chatUser, inputIDs, previewIDs []int) {
|
||||
for _, id := range inputIDs {
|
||||
t.deleteIncomingID(actor.chatID, id)
|
||||
}
|
||||
for _, id := range previewIDs {
|
||||
t.deleteOwnMessage(actor.chatID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// recallBroadcast undoes a cancelled broadcast by removing copies from every
|
||||
// recipient. Best-effort: Telegram refuses deleteMessage older than 48 hours.
|
||||
func (t *Tgbot) recallBroadcast(chatID int64, ids []int) {
|
||||
for _, id := range ids {
|
||||
t.deleteOwnMessage(chatID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// bufferBroadcastMedia appends an album item; the debounce timer fires the
|
||||
@@ -268,6 +353,8 @@ func (t *Tgbot) finalizeBroadcastAlbum(actor chatUser, groupID string) {
|
||||
t.acceptBroadcastDraft(actor, ids)
|
||||
}
|
||||
|
||||
// The broadcast flow stays message-scoped, not screen-scoped: in a shared group
|
||||
// a per-chat screen would make one admin's preview overwrite another's confirm token.
|
||||
func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup {
|
||||
return tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("broadcast_cancel"),
|
||||
@@ -278,7 +365,7 @@ func (t *Tgbot) broadcastCancelKeyboard() *telego.InlineKeyboardMarkup {
|
||||
// runner slot, replaces the preview with a progress card, and starts delivery.
|
||||
func (t *Tgbot) confirmBroadcast(actor chatUser, token string, messageID int, queryID string) {
|
||||
chatId := actor.chatID
|
||||
runner := broadcastRegisterRunner(chatId)
|
||||
runner := broadcastRegisterRunner(actor)
|
||||
if runner == nil {
|
||||
t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.messages.broadcastAlreadyRunning"))
|
||||
return
|
||||
@@ -317,13 +404,15 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci
|
||||
sent, failed, unreachable := 0, 0, 0
|
||||
lastProgress := time.Now()
|
||||
canceled := false
|
||||
// What each recipient actually received, so a cancel can take it back.
|
||||
delivered := map[int64][]int{}
|
||||
|
||||
for i, chatID := range recipients {
|
||||
if aborted() {
|
||||
canceled = runner.cancel.Load()
|
||||
break
|
||||
}
|
||||
err := broadcastDeliverOne(chatID, draft, aborted)
|
||||
ids, err := broadcastDeliverOne(chatID, draft, aborted)
|
||||
if errors.Is(err, errBroadcastAborted) {
|
||||
canceled = runner.cancel.Load()
|
||||
break
|
||||
@@ -331,6 +420,7 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci
|
||||
switch {
|
||||
case err == nil:
|
||||
sent++
|
||||
delivered[chatID] = ids
|
||||
case broadcastChatUnreachable(err):
|
||||
// 403 means the chat never started the bot or blocked it; a long
|
||||
// recipient list would turn these into log spam at warning level.
|
||||
@@ -359,10 +449,20 @@ func (t *Tgbot) runBroadcast(runner *broadcastRunner, draft broadcastDraft, reci
|
||||
Canceled: canceled,
|
||||
Elapsed: time.Since(start).Round(time.Second),
|
||||
}
|
||||
// A cancelled broadcast that says "this is what went out" and leaves it in
|
||||
// every chat is a lie: the copies are withdrawn before that summary is drawn.
|
||||
if canceled {
|
||||
for chatID, ids := range delivered {
|
||||
t.recallBroadcast(chatID, ids)
|
||||
}
|
||||
}
|
||||
summary := t.broadcastSummaryText(result)
|
||||
if !t.finalizeBroadcastCard(runner, summary) {
|
||||
t.SendMsgToTgbot(runner.chatID, summary)
|
||||
}
|
||||
// The card is now the only message the run leaves behind: the admin's input
|
||||
// and its preview copy are composing artefacts, not the broadcast.
|
||||
t.clearBroadcastDraft(runner.actor, draft.MessageIDs, nil)
|
||||
logger.Info("broadcast finished: delivered", sent, "failed", failed, "skipped", result.Skipped, "elapsed", result.Elapsed)
|
||||
runner.setResult(result)
|
||||
}
|
||||
@@ -397,13 +497,15 @@ func (t *Tgbot) broadcastSummaryText(result broadcastResult) string {
|
||||
// the card is gone and the summary needs its own message to be seen at all.
|
||||
func (t *Tgbot) finalizeBroadcastCard(runner *broadcastRunner, summary string) bool {
|
||||
params := telego.EditMessageTextParams{
|
||||
ChatID: tu.ID(runner.chatID),
|
||||
MessageID: runner.messageID,
|
||||
Text: summary,
|
||||
ParseMode: "HTML",
|
||||
ReplyMarkup: &telego.InlineKeyboardMarkup{InlineKeyboard: [][]telego.InlineKeyboardButton{}},
|
||||
ChatID: tu.ID(runner.chatID),
|
||||
MessageID: runner.messageID,
|
||||
Text: summary,
|
||||
ParseMode: "HTML",
|
||||
// The run is over: its own controls go, and what remains is the same
|
||||
// "🙈 Скрыть" every other finished message offers.
|
||||
ReplyMarkup: t.hideButton(),
|
||||
}
|
||||
_, err := bot.EditMessageText(context.Background(), ¶ms)
|
||||
_, err := liveBot().EditMessageText(context.Background(), ¶ms)
|
||||
if err == nil || isTelegramNotModifiedError(err) {
|
||||
return true
|
||||
}
|
||||
@@ -420,8 +522,13 @@ func (t *Tgbot) cancelBroadcast(actor chatUser, messageID int, queryID string) {
|
||||
t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceling"))
|
||||
return
|
||||
}
|
||||
// Dropping a broadcast before it ran removes the admin's input, its preview
|
||||
// copy and both prompt cards, leaving the chat as it was before /broadcast.
|
||||
inputIDs := broadcastComposeInputs(actor)
|
||||
broadcastDropCompose(actor)
|
||||
userStateMgr.clear(actor)
|
||||
t.clearBroadcastPrompt(actor)
|
||||
t.clearBroadcastDraft(actor, inputIDs, nil)
|
||||
t.deleteMessageTgBot(chatId, messageID)
|
||||
t.sendCallbackAnswerTgBot(queryID, t.I18nBot("tgbot.answers.broadcastCanceled"))
|
||||
}
|
||||
@@ -454,19 +561,19 @@ func (t *Tgbot) collectBroadcastRecipients() []int64 {
|
||||
|
||||
// broadcastDeliverOne retries a recipient through flood-control waits so a 429
|
||||
// never drops them; after broadcastFloodRetries waits it gives up on them.
|
||||
func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) error {
|
||||
func broadcastDeliverOne(chatID int64, draft broadcastDraft, aborted func() bool) ([]int, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := broadcastSender(chatID, draft)
|
||||
ids, err := broadcastSender(chatID, draft)
|
||||
if err == nil {
|
||||
return nil
|
||||
return ids, nil
|
||||
}
|
||||
wait, flood := broadcastRetryAfter(err)
|
||||
if !flood || attempt >= broadcastFloodRetries {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
logger.Warningf("broadcast: chat %d is flood-limited, retrying in %s", chatID, wait)
|
||||
if !broadcastFloodWait(wait, aborted) {
|
||||
return errBroadcastAborted
|
||||
return nil, errBroadcastAborted
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,20 +609,50 @@ func broadcastRetryAfter(err error) (time.Duration, bool) {
|
||||
return time.Duration(apiErr.Parameters.RetryAfter) * time.Second, true
|
||||
}
|
||||
|
||||
// deliverBroadcastCopy copies the admin's message to one recipient chat; an
|
||||
// album rides one copyMessages call and arrives with no forward header.
|
||||
func deliverBroadcastCopy(chatID int64, draft broadcastDraft) error {
|
||||
// deliverBroadcastCopy copies the admin's message into one chat (an album rides
|
||||
// one copyMessages call, no forward header) and returns the created ids.
|
||||
func deliverBroadcastCopy(chatID int64, draft broadcastDraft) ([]int, error) {
|
||||
from := tu.ID(draft.FromChatID)
|
||||
return callTelegramAPI(func(ctx context.Context) error {
|
||||
var ids []int
|
||||
err := callTelegramAPI(func(ctx context.Context) error {
|
||||
var err error
|
||||
switch {
|
||||
case len(draft.MessageIDs) > 1:
|
||||
_, err = bot.CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs})
|
||||
var copied []telego.MessageID
|
||||
copied, err = liveBot().CopyMessages(ctx, &telego.CopyMessagesParams{ChatID: tu.ID(chatID), FromChatID: from, MessageIDs: draft.MessageIDs})
|
||||
for _, m := range copied {
|
||||
ids = append(ids, m.MessageID)
|
||||
}
|
||||
case len(draft.MessageIDs) == 1:
|
||||
_, err = bot.CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]})
|
||||
var copied *telego.MessageID
|
||||
copied, err = liveBot().CopyMessage(ctx, &telego.CopyMessageParams{ChatID: tu.ID(chatID), FromChatID: from, MessageID: draft.MessageIDs[0]})
|
||||
if copied != nil {
|
||||
ids = append(ids, copied.MessageID)
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
// Every recipient can put the message away (a client has no panel menu to
|
||||
// clear it from); the content is untouched, only the keyboard is added.
|
||||
if err == nil {
|
||||
for _, id := range ids {
|
||||
markupBroadcastCopy(chatID, id)
|
||||
}
|
||||
}
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// markupBroadcastCopy puts the hide button on a delivered copy. Best-effort: a
|
||||
// failure here leaves the broadcast intact and is not worth retrying the send.
|
||||
func markupBroadcastCopy(chatID int64, messageID int) {
|
||||
_ = callTelegramAPI(func(ctx context.Context) error {
|
||||
_, err := liveBot().EditMessageReplyMarkup(ctx, &telego.EditMessageReplyMarkupParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageID: messageID,
|
||||
ReplyMarkup: hideButtonMarkup(),
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func callTelegramAPI(call func(ctx context.Context) error) error {
|
||||
|
||||
@@ -41,6 +41,11 @@ func newBroadcastMock(t *testing.T) (url string, calls func(string) int, bodies
|
||||
if method == "copyMessages" {
|
||||
result = []any{message, message}
|
||||
}
|
||||
if method == "sendPhoto" {
|
||||
// Screens are photo messages whose caption and keyboard travel as form fields; the mock lifts them into
|
||||
// the JSON shape the assertions read.
|
||||
payload["text"] = payload["caption"]
|
||||
}
|
||||
mu.Lock()
|
||||
counts[method]++
|
||||
sent[method] = append(sent[method], payload)
|
||||
@@ -88,7 +93,7 @@ func setBroadcastRunning(t *testing.T, running bool) {
|
||||
})
|
||||
}
|
||||
|
||||
func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) error, pause func(time.Duration)) {
|
||||
func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) ([]int, error), pause func(time.Duration)) {
|
||||
t.Helper()
|
||||
origSend, origPause := broadcastSender, broadcastPause
|
||||
t.Cleanup(func() {
|
||||
@@ -226,8 +231,11 @@ func TestRunBroadcastCounters(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error {
|
||||
return tt.outcomes[chatID]
|
||||
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) {
|
||||
if err := tt.outcomes[chatID]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []int{int(chatID)}, nil
|
||||
}, func(time.Duration) {})
|
||||
|
||||
runner := &broadcastRunner{chatID: 100, messageID: 5}
|
||||
@@ -259,11 +267,11 @@ func TestRunBroadcastCancelsMidway(t *testing.T) {
|
||||
setBroadcastRunning(t, true)
|
||||
|
||||
runner := &broadcastRunner{chatID: 100, messageID: 5}
|
||||
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error {
|
||||
swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) ([]int, error) {
|
||||
if chatID == 1 {
|
||||
runner.cancel.Store(true)
|
||||
}
|
||||
return nil
|
||||
return []int{int(chatID)}, nil
|
||||
}, func(time.Duration) {})
|
||||
|
||||
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
|
||||
@@ -288,7 +296,7 @@ func TestRunBroadcastUnreachableKeepsProgressThrottled(t *testing.T) {
|
||||
swapTestBot(t, url)
|
||||
setBroadcastRunning(t, true)
|
||||
blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot can't initiate conversation with a user"}
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) error { return blocked }, func(time.Duration) {})
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, blocked }, func(time.Duration) {})
|
||||
|
||||
runner := &broadcastRunner{chatID: 100, messageID: 5}
|
||||
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
|
||||
@@ -321,7 +329,7 @@ func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var pauses []time.Duration
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) error { return flood }, func(d time.Duration) {
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, flood }, func(d time.Duration) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
pauses = append(pauses, d)
|
||||
@@ -335,7 +343,7 @@ func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) {
|
||||
return checks > tt.abortAfter
|
||||
}
|
||||
|
||||
err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted)
|
||||
_, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
@@ -378,7 +386,7 @@ func TestStopBotResetsBroadcastState(t *testing.T) {
|
||||
MessageID: 1,
|
||||
MediaGroupID: "grpR",
|
||||
})
|
||||
runner := broadcastRegisterRunner(chatID)
|
||||
runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID})
|
||||
if runner == nil {
|
||||
t.Fatal("broadcastRegisterRunner() = nil before the stop")
|
||||
}
|
||||
@@ -408,7 +416,7 @@ func TestRunBroadcastAlbumPacing(t *testing.T) {
|
||||
setBroadcastRunning(t, true)
|
||||
|
||||
var pauses []time.Duration
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) error { return nil }, func(d time.Duration) {
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) { return nil, nil }, func(d time.Duration) {
|
||||
pauses = append(pauses, d)
|
||||
})
|
||||
|
||||
@@ -469,14 +477,17 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
callNum := 0
|
||||
var pauses []time.Duration
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) error {
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callNum++
|
||||
if callNum > len(tt.responses) {
|
||||
return nil
|
||||
return []int{callNum}, nil
|
||||
}
|
||||
return tt.responses[callNum-1]
|
||||
if err := tt.responses[callNum-1]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []int{callNum}, nil
|
||||
}, func(d time.Duration) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
@@ -484,7 +495,7 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) {
|
||||
})
|
||||
canceled := func() bool { return tt.canceled }
|
||||
|
||||
err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled)
|
||||
_, err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
@@ -511,13 +522,13 @@ func TestBroadcastDeliverOneRetries429(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBroadcastRegisterRunnerSingleSlot(t *testing.T) {
|
||||
first := broadcastRegisterRunner(1)
|
||||
first := broadcastRegisterRunner(chatUser{chatID: 1})
|
||||
if first == nil {
|
||||
t.Fatal("broadcastRegisterRunner() = nil for an idle bot")
|
||||
}
|
||||
t.Cleanup(func() { broadcastUnregisterRunner(first) })
|
||||
|
||||
if second := broadcastRegisterRunner(2); second != nil {
|
||||
if second := broadcastRegisterRunner(chatUser{chatID: 2}); second != nil {
|
||||
t.Fatalf("broadcastRegisterRunner() = %v while a broadcast is running, want nil", second)
|
||||
}
|
||||
|
||||
@@ -532,7 +543,7 @@ func TestStartBroadcastRefusesWhileRunning(t *testing.T) {
|
||||
const chatID = int64(9102)
|
||||
resetBroadcastState(t)
|
||||
|
||||
runner := broadcastRegisterRunner(chatID)
|
||||
runner := broadcastRegisterRunner(chatUser{chatID: chatID, userID: chatID})
|
||||
t.Cleanup(func() {
|
||||
broadcastUnregisterRunner(runner)
|
||||
})
|
||||
@@ -615,7 +626,7 @@ func TestDeliverBroadcastCopy(t *testing.T) {
|
||||
url, calls, bodies := newBroadcastMock(t)
|
||||
swapTestBot(t, url)
|
||||
|
||||
if err := deliverBroadcastCopy(66, tt.draft); err != nil {
|
||||
if _, err := deliverBroadcastCopy(66, tt.draft); err != nil {
|
||||
t.Fatalf("deliverBroadcastCopy() error = %v", err)
|
||||
}
|
||||
for method, want := range tt.wantMethods {
|
||||
@@ -939,3 +950,83 @@ func TestBroadcastCallbacksDeniedToNonAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastComposeToken reads the token the pending draft was stored under.
|
||||
func broadcastComposeToken(t *testing.T, actor chatUser) string {
|
||||
t.Helper()
|
||||
_, token, ok := broadcastPendingDraft(actor)
|
||||
if !ok {
|
||||
t.Fatal("no pending draft to confirm")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// The admin's composing message is the broadcast's source, not the broadcast: keeping it, its preview
|
||||
// and the card put three copies of the text on screen.
|
||||
func TestBroadcastCleansUpItsComposingMessages(t *testing.T) {
|
||||
srv, calls := recordingServer(t, false)
|
||||
swapTestBot(t, srv.URL)
|
||||
t.Cleanup(srv.Close)
|
||||
broadcastLocalizer(t)
|
||||
setBroadcastRunning(t, true)
|
||||
t.Cleanup(func() { resetBroadcastState(t) })
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
|
||||
// The id Telegram returns for the self-copy preview.
|
||||
return []int{321}, nil
|
||||
}, func(time.Duration) {})
|
||||
|
||||
tb := initReportDB(t)
|
||||
const chatID = int64(9100)
|
||||
admin := chatUser{chatID: chatID, userID: 1}
|
||||
|
||||
tb.handleBroadcastInput(&telego.Message{
|
||||
MessageID: 777,
|
||||
Chat: telego.Chat{ID: chatID},
|
||||
From: &telego.User{ID: 1},
|
||||
Text: "hello everyone",
|
||||
}, admin)
|
||||
|
||||
// The preview copy is the broadcast content: it must leave the chat at once.
|
||||
if got := countMethod(calls(), "deleteMessage"); got == 0 {
|
||||
t.Fatal("the self-copy preview was left in the chat")
|
||||
}
|
||||
|
||||
// Confirming must also remove the admin's own input message.
|
||||
tb.confirmBroadcast(admin, broadcastComposeToken(t, admin), 55, "q1")
|
||||
|
||||
if got := countMethod(calls(), "deleteMessage"); got < 2 {
|
||||
t.Errorf("deleteMessage calls = %d, want the input and the preview removed", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A cancelled broadcast must not leave its copies in the recipients' chats: the
|
||||
// summary would say it stopped while every chat that got one still shows it.
|
||||
func TestCancelledBroadcastRecallsDeliveredCopies(t *testing.T) {
|
||||
srv, calls := recordingServer(t, false)
|
||||
swapTestBot(t, srv.URL)
|
||||
t.Cleanup(srv.Close)
|
||||
broadcastLocalizer(t)
|
||||
setBroadcastRunning(t, true)
|
||||
t.Cleanup(func() { resetBroadcastState(t) })
|
||||
initReportDB(t)
|
||||
|
||||
runner := &broadcastRunner{actor: chatUser{chatID: 100}, chatID: 100, messageID: 5}
|
||||
delivered := 0
|
||||
swapBroadcastSender(t, func(int64, broadcastDraft) ([]int, error) {
|
||||
delivered++
|
||||
// The admin cancels mid-run: what already went out has to be withdrawn.
|
||||
if delivered == 2 {
|
||||
runner.cancel.Store(true)
|
||||
}
|
||||
return []int{900 + delivered}, nil
|
||||
}, func(time.Duration) {})
|
||||
|
||||
(&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3})
|
||||
|
||||
if delivered == 0 {
|
||||
t.Fatal("nothing was delivered, so there is nothing to recall")
|
||||
}
|
||||
if got := countMethod(calls(), "deleteMessage"); got < delivered {
|
||||
t.Errorf("deleteMessage calls = %d, want one per delivered copy (%d)", got, delivered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
@@ -20,7 +18,6 @@ import (
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress
|
||||
@@ -224,28 +221,6 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
||||
return subURL, subJsonURL, nil
|
||||
}
|
||||
|
||||
// sendClientSubLinks sends the subscription links for the client to the chat.
|
||||
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
|
||||
if subJsonURL != "" {
|
||||
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||
}
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
|
||||
),
|
||||
)
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
}
|
||||
|
||||
// clientSubLinks builds the subscription's links in-process for the host subURL
|
||||
// names; fetching subURL instead fails whenever that host does not resolve here.
|
||||
func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) {
|
||||
@@ -260,105 +235,6 @@ func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) {
|
||||
return t.inboundService.GetSubLinks(u.Hostname(), client.SubID)
|
||||
}
|
||||
|
||||
// sendClientIndividualLinks sends the subscription's individual links to the user
|
||||
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
|
||||
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
links, err := t.clientSubLinks(email, subURL)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
if len(links) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
|
||||
// Send in chunks to respect message length; use monospace formatting
|
||||
const maxPerMessage = 50
|
||||
for i := 0; i < len(links); i += maxPerMessage {
|
||||
j := min(i+maxPerMessage, len(links))
|
||||
chunk := links[i:j]
|
||||
var msg strings.Builder
|
||||
msg.WriteString(t.I18nBot("subscription.individualLinks"))
|
||||
msg.WriteString(":\r\n")
|
||||
for _, link := range chunk {
|
||||
// wrap each link in <code>
|
||||
msg.WriteString("<code>")
|
||||
msg.WriteString(link)
|
||||
msg.WriteString("</code>\r\n")
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, msg.String())
|
||||
}
|
||||
}
|
||||
|
||||
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
|
||||
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Helper to create QR PNG bytes from content
|
||||
createQR := func(content string, size int) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
size = 256
|
||||
}
|
||||
return qrcode.Encode(content, qrcode.Medium, size)
|
||||
}
|
||||
|
||||
// Inform user
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email))
|
||||
|
||||
// Send sub URL QR (filename: sub.png)
|
||||
if png, err := createQR(subURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "sub.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
|
||||
// Send JSON URL QR (filename: subjson.png) when available
|
||||
if subJsonURL != "" {
|
||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "subjson.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Also generate a few individual links' QRs (first up to 5)
|
||||
if links, err := t.clientSubLinks(email, subURL); err == nil {
|
||||
max := min(len(links), 5)
|
||||
for i := range max {
|
||||
if png, err := createQR(links[i], 320); err == nil {
|
||||
// Use the email as filename for individual link QR
|
||||
filename := email + ".png"
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, filename),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
// Reduced delay for better performance
|
||||
if i < max-1 { // Only delay between documents, not after the last one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientInfoMsg formats client information message based on traffic and flags.
|
||||
func (t *Tgbot) clientInfoMsg(
|
||||
traffic *xray.ClientTraffic,
|
||||
@@ -503,13 +379,20 @@ func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
||||
return
|
||||
}
|
||||
|
||||
// A client sees only its own card, and never the admin keyboard: every button
|
||||
// on it (reset traffic, limits, IP limit, enable/disable) acts on an account.
|
||||
if t.levelOf(tgUserID) != levelAdmin {
|
||||
t.screenClientHome(chatId, tgUserID)
|
||||
return
|
||||
}
|
||||
|
||||
output := ""
|
||||
|
||||
if len(traffics) > 0 {
|
||||
if len(email) > 0 {
|
||||
for _, traffic := range traffics {
|
||||
if traffic.Email == email[0] {
|
||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, false)
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
return
|
||||
}
|
||||
@@ -526,169 +409,13 @@ func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
||||
}
|
||||
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
output = t.I18nBot("tgbot.commands.pleaseChoose")
|
||||
t.SendAnswer(chatId, output, false)
|
||||
}
|
||||
|
||||
// searchClientIps searches and sends client IP addresses for the given email.
|
||||
func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
||||
ips, err := t.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || len(ips) == 0 {
|
||||
ips = t.I18nBot("tgbot.noIpRecord")
|
||||
}
|
||||
|
||||
formattedIps := ips
|
||||
if err == nil && len(ips) > 0 {
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var ipsWithTime []ipWithTimestamp
|
||||
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
|
||||
lines := make([]string, 0, len(ipsWithTime))
|
||||
for _, item := range ipsWithTime {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
|
||||
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, item.IP)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
formattedIps = strings.Join(lines, "\n")
|
||||
}
|
||||
} else {
|
||||
var oldIps []string
|
||||
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
|
||||
formattedIps = strings.Join(oldIps, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)),
|
||||
),
|
||||
)
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// clientTelegramUserInfo retrieves and sends Telegram user info for the client.
|
||||
func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) {
|
||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if client == nil {
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
tgId := "None"
|
||||
if client.TgID != 0 {
|
||||
tgId = strconv.FormatInt(client.TgID, 10)
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||
output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId)
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)),
|
||||
),
|
||||
)
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
requestUser := telego.KeyboardButtonRequestUsers{
|
||||
RequestID: int32(traffic.Id),
|
||||
UserIsBot: new(bool),
|
||||
}
|
||||
keyboard := tu.Keyboard(
|
||||
tu.KeyboardRow(
|
||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser),
|
||||
),
|
||||
tu.KeyboardRow(
|
||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")),
|
||||
),
|
||||
).WithIsPersistent().WithResizeKeyboard()
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// searchClient searches for a client by email and sends the information.
|
||||
func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if traffic == nil {
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.inviteLink")).WithCallbackData(t.encodeQuery("client_invite_link "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)),
|
||||
),
|
||||
)
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
rows := [][]telego.InlineKeyboardButton{}
|
||||
if len(email) > 0 {
|
||||
rows = t.clientRows(email[0])
|
||||
} else if len(traffics) > 0 {
|
||||
rows = t.clientRows(traffics[0].Email)
|
||||
}
|
||||
t.renderScreen(chatId, t.newScreen("client", output, rows...))
|
||||
}
|
||||
|
||||
// getCommonClientButtons returns the shared inline keyboard rows for the
|
||||
@@ -696,7 +423,8 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
||||
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
||||
// only exposes the universal client fields here.
|
||||
func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyboardButton {
|
||||
attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(draft.receiverInboundIDs))
|
||||
attachLabel := t.I18nBot("tgbot.buttons.attachInbound",
|
||||
"Count=="+strconv.Itoa(len(draft.receiverInboundIDs)))
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||
@@ -723,12 +451,15 @@ func (t *Tgbot) getCommonClientButtons(draft *clientDraft) [][]telego.InlineKeyb
|
||||
}
|
||||
}
|
||||
|
||||
// addClient renders the draft message + shared client-first keyboard.
|
||||
// addClient renders the draft as the chat's screen: the wizard keeps ONE
|
||||
// message, and a value picker can edit it without losing the card.
|
||||
func (t *Tgbot) addClient(chatId int64, draft *clientDraft, msg string, messageID ...int) {
|
||||
inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons(draft)...)
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
t.adoptScreen(chatId, messageID[0])
|
||||
}
|
||||
body := msg
|
||||
if body == "" {
|
||||
body = t.BuildClientDraftMessage(draft)
|
||||
}
|
||||
t.renderScreen(chatId, t.newScreen("wizard", body, t.getCommonClientButtons(draft)...))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -23,23 +22,15 @@ func draftTexts(t *testing.T) (string, func(int64) []string) {
|
||||
var mu sync.Mutex
|
||||
texts := map[int64][]string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
result := any(true)
|
||||
if r.URL.Path == "/bot"+testBotToken+"/sendMessage" || r.URL.Path == "/bot"+testBotToken+"/editMessageText" {
|
||||
var payload struct {
|
||||
ChatID any `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
chatID := int64(0)
|
||||
switch v := payload.ChatID.(type) {
|
||||
case float64:
|
||||
chatID = int64(v)
|
||||
}
|
||||
switch method {
|
||||
case "sendMessage", "sendPhoto", "editMessageText", "editMessageMedia", "editMessageCaption":
|
||||
sent := extractSentBody(t, r)
|
||||
mu.Lock()
|
||||
texts[chatID] = append(texts[chatID], payload.Text)
|
||||
texts[sent.ChatID] = append(texts[sent.ChatID], sent.Text)
|
||||
mu.Unlock()
|
||||
result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": chatID, "type": "private"}}
|
||||
result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"}}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
|
||||
|
||||
@@ -31,6 +31,14 @@ func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int
|
||||
"date": 0,
|
||||
"chat": map[string]any{"id": ownerTgID, "type": "private"},
|
||||
}},
|
||||
// The owner's links draw on the tracked screen, which is a photo.
|
||||
"sendPhoto": map[string]any{"ok": true, "result": map[string]any{
|
||||
"message_id": 1,
|
||||
"date": 0,
|
||||
"chat": map[string]any{"id": ownerTgID, "type": "private"},
|
||||
}},
|
||||
"deleteMessage": map[string]any{"ok": true, "result": true},
|
||||
"answerInlineQuery": map[string]any{"ok": true, "result": true},
|
||||
})
|
||||
swapTestBot(t, mock.URL)
|
||||
t.Cleanup(mock.Close)
|
||||
@@ -87,8 +95,8 @@ func TestClientLinkCallbackRefusesForeignClient(t *testing.T) {
|
||||
|
||||
tapClientLinks(t, tb, ownerTgID, "client_sub_links someone-else@x")
|
||||
|
||||
if n := calls("sendMessage"); n != 0 {
|
||||
t.Errorf("sendMessage calls = %d, want 0: a non-admin received a foreign client's links", n)
|
||||
if n := calls("sendMessage") + calls("sendPhoto"); n != 0 {
|
||||
t.Errorf("message calls = %d, want 0: a non-admin received a foreign client's links", n)
|
||||
}
|
||||
if n := calls("answerCallbackQuery"); n != 1 {
|
||||
t.Errorf("answerCallbackQuery calls = %d, want 1: the refused tap must be answered", n)
|
||||
@@ -101,8 +109,8 @@ func TestClientLinkCallbackServesOwnClient(t *testing.T) {
|
||||
|
||||
tapClientLinks(t, tb, ownerTgID, "client_sub_links "+ownerMail)
|
||||
|
||||
if n := calls("sendMessage"); n != 1 {
|
||||
t.Errorf("sendMessage calls = %d, want 1: the owner must still get its links", n)
|
||||
if n := calls("sendMessage") + calls("sendPhoto"); n != 1 {
|
||||
t.Errorf("message calls = %d, want 1: the owner must still get its links", n)
|
||||
}
|
||||
if n := calls("answerCallbackQuery"); n != 0 {
|
||||
t.Errorf("answerCallbackQuery calls = %d, want 0: an allowed tap is not refused", n)
|
||||
@@ -144,7 +152,7 @@ func TestHashedLinkCallbackServesOwnClient(t *testing.T) {
|
||||
}
|
||||
tapClientLinks(t, tb, ownerTgID, data)
|
||||
|
||||
if n := calls("sendMessage"); n != 1 {
|
||||
t.Errorf("sendMessage calls = %d, want 1: the owner's hashed button must still be served", n)
|
||||
if n := calls("sendMessage") + calls("sendPhoto"); n != 1 {
|
||||
t.Errorf("message calls = %d, want 1: the owner's hashed button must still be served", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package tgbot
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) {
|
||||
userStateMgr.reset()
|
||||
t.Cleanup(userStateMgr.reset)
|
||||
|
||||
const chatID = int64(4242)
|
||||
userStateMgr.set(chatUser{chatID: chatID, userID: 1}, "awaiting_comment")
|
||||
|
||||
tg := &Tgbot{}
|
||||
tg.deleteMessageAfterDelay(chatID, 1, 0)
|
||||
|
||||
if st, ok := userStateMgr.get(chatUser{chatID: chatID, userID: 1}); !ok || st != "awaiting_comment" {
|
||||
t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
)
|
||||
@@ -25,23 +24,31 @@ func getHostname() string {
|
||||
return cachedHostname
|
||||
}
|
||||
|
||||
var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
|
||||
|
||||
// HandleEvent is the eventbus subscriber callback. It formats incoming events
|
||||
// as Telegram messages and sends them to all admin chats.
|
||||
// HandleEvent is the eventbus subscriber callback: one kind of event keeps ONE
|
||||
// live card per admin chat, so a repeat edits it instead of a wall of messages.
|
||||
func (t *Tgbot) HandleEvent(e eventbus.Event) {
|
||||
if !t.isEventEnabled(e.Type) {
|
||||
return
|
||||
}
|
||||
if e.Type != eventbus.EventLoginAttempt {
|
||||
if !tgEventLimiter.Allow(e.Type, e.Source) {
|
||||
return
|
||||
msg := t.formatEventMessage(e)
|
||||
if msg == "" {
|
||||
return
|
||||
}
|
||||
kind := t.eventNoticeKind(e)
|
||||
for _, adminID := range adminSnapshot() {
|
||||
t.liveNotice(adminID, kind, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// eventNoticeKind is the live card a repeat of this event edits. A login success
|
||||
// must not fold into the failure card from the same IP: outcome is part of the key.
|
||||
func (t *Tgbot) eventNoticeKind(e eventbus.Event) string {
|
||||
if e.Type == eventbus.EventLoginAttempt {
|
||||
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
|
||||
return string(e.Type) + ":" + e.Source + ":" + data.Status
|
||||
}
|
||||
}
|
||||
msg := t.formatEventMessage(e)
|
||||
if msg != "" {
|
||||
t.SendMsgToTgbotAdmins(msg)
|
||||
}
|
||||
return string(e.Type) + ":" + e.Source
|
||||
}
|
||||
|
||||
func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
)
|
||||
|
||||
const testAdminID = 1
|
||||
|
||||
func privateMessage(id int, text string) telego.Update {
|
||||
return telego.Update{UpdateID: id, Message: &telego.Message{
|
||||
MessageID: id, Text: text,
|
||||
Chat: telego.Chat{ID: testAdminID, Type: telego.ChatTypePrivate},
|
||||
From: &telego.User{ID: testAdminID, FirstName: "a"},
|
||||
}}
|
||||
}
|
||||
|
||||
func groupMessage(id int, text string) telego.Update {
|
||||
return telego.Update{UpdateID: id, Message: &telego.Message{
|
||||
MessageID: id, Text: text,
|
||||
Chat: telego.Chat{ID: -900, Type: telego.ChatTypeSupergroup},
|
||||
From: &telego.User{ID: testAdminID, FirstName: "a"},
|
||||
}}
|
||||
}
|
||||
|
||||
func groupCallback(id int, data string) telego.Update {
|
||||
return telego.Update{UpdateID: id, CallbackQuery: &telego.CallbackQuery{
|
||||
ID: "cq", Data: data,
|
||||
From: telego.User{ID: testAdminID, FirstName: "a"},
|
||||
Message: &telego.Message{
|
||||
MessageID: id,
|
||||
Chat: telego.Chat{ID: -900, Type: telego.ChatTypeSupergroup},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// A group shares one screen and keyboard among all members, so admins and clients overwrite each other;
|
||||
// the guard must answer a group and stop it before any screen is drawn.
|
||||
func TestGroupUpdatesAreRefused(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
h, err := th.NewBotHandler(liveBot(), make(chan telego.Update, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("NewBotHandler: %v", err)
|
||||
}
|
||||
refuseGroups(h, tb)
|
||||
|
||||
for _, update := range []telego.Update{
|
||||
groupMessage(1, "/start"),
|
||||
groupCallback(2, cbHome),
|
||||
{UpdateID: 3, InlineQuery: &telego.InlineQuery{ID: "q", ChatType: telego.ChatTypeGroup}},
|
||||
} {
|
||||
if err := h.BaseGroup().HandleUpdate(context.Background(), liveBot(), update); err != nil {
|
||||
t.Fatalf("HandleUpdate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got := calls()
|
||||
if indexOf(got, "sendPhoto") >= 0 || indexOf(got, "editMessageText") >= 0 {
|
||||
t.Fatalf("a group drew a screen: %v", methods(got))
|
||||
}
|
||||
want := tb.I18nBot("tgbot.messages.privateOnly")
|
||||
sawNotice := false
|
||||
for _, c := range got {
|
||||
if c.Method == "sendMessage" && c.text() == want {
|
||||
sawNotice = true
|
||||
}
|
||||
}
|
||||
if !sawNotice {
|
||||
t.Fatalf("the group was not told why nothing happened: %v", methods(got))
|
||||
}
|
||||
if indexOf(got, "answerCallbackQuery") < 0 {
|
||||
t.Fatalf("the group callback was left unanswered: %v", methods(got))
|
||||
}
|
||||
if indexOf(got, "answerInlineQuery") < 0 {
|
||||
t.Fatalf("the group inline query was left unanswered: %v", methods(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A private update must fall through the guard untouched: the close-keyboard
|
||||
// route runs synchronously, so its delete is visible without a worker goroutine.
|
||||
func TestPrivateChatIsNotRefused(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
h, err := th.NewBotHandler(liveBot(), make(chan telego.Update, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("NewBotHandler: %v", err)
|
||||
}
|
||||
refuseGroups(h, tb)
|
||||
h.HandleMessage(func(*th.Context, telego.Message) error { return nil },
|
||||
th.TextEqual(tb.I18nBot("tgbot.buttons.closeKeyboard")))
|
||||
|
||||
if err := h.BaseGroup().HandleUpdate(context.Background(), liveBot(),
|
||||
privateMessage(1, tb.I18nBot("tgbot.buttons.closeKeyboard"))); err != nil {
|
||||
t.Fatalf("HandleUpdate: %v", err)
|
||||
}
|
||||
|
||||
got := calls()
|
||||
for _, c := range got {
|
||||
if c.text() == tb.I18nBot("tgbot.messages.privateOnly") {
|
||||
t.Fatalf("a private chat was refused: %v", methods(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateChatType is the guard's only input, so it is pinned per update shape.
|
||||
func TestUpdateChatType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
up telego.Update
|
||||
want string
|
||||
}{
|
||||
{"private message", privateMessage(1, "x"), telego.ChatTypePrivate},
|
||||
{"group message", groupMessage(1, "x"), telego.ChatTypeSupergroup},
|
||||
{"inline from private chat (sender)", telego.Update{InlineQuery: &telego.InlineQuery{
|
||||
ID: "q", ChatType: "sender",
|
||||
}}, telego.ChatTypePrivate},
|
||||
{"inline from a group", telego.Update{InlineQuery: &telego.InlineQuery{
|
||||
ID: "q", ChatType: telego.ChatTypeGroup,
|
||||
}}, telego.ChatTypeGroup},
|
||||
{"empty inline chat type", telego.Update{InlineQuery: &telego.InlineQuery{ID: "q"}}, telego.ChatTypePrivate},
|
||||
{"unknown update", telego.Update{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := updateChatType(tc.up); got != tc.want {
|
||||
t.Errorf("%s: updateChatType = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,98 +44,6 @@ func (t *Tgbot) getInboundUsages() string {
|
||||
return info.String()
|
||||
}
|
||||
|
||||
// getInbounds creates an inline keyboard with all inbounds.
|
||||
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsFor builds an inline keyboard of inbounds for a custom next action.
|
||||
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
|
||||
func (t *Tgbot) getInboundClientsFor(inbound *model.Inbound, action string) (*telego.InlineKeyboardMarkup, error) {
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("GetInboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
} else {
|
||||
if len(clients) > 0 {
|
||||
for _, client := range clients {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
||||
}
|
||||
}
|
||||
cols := 0
|
||||
if len(buttons) < 6 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// addClientExcludedProtocols are the protocols with no per-client model: Tunnel
|
||||
// has no clients, Mixed/HTTP authenticate at the inbound level, not per-client.
|
||||
var addClientExcludedProtocols = map[model.Protocol]bool{
|
||||
@@ -144,47 +52,6 @@ var addClientExcludedProtocols = map[model.Protocol]bool{
|
||||
model.HTTP: true,
|
||||
}
|
||||
|
||||
// getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
|
||||
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
if addClientExcludedProtocols[inbound.Protocol] {
|
||||
continue
|
||||
}
|
||||
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
if len(buttons) == 0 {
|
||||
logger.Warning("No inbounds eligible for add-client (all excluded by protocol)")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsAttachPicker builds a toggle picker over multi-client inbounds
|
||||
// for the "attach more inbounds to the new client" step. Each row shows the
|
||||
// current selection state for the inbound; tapping fires
|
||||
@@ -222,42 +89,25 @@ func (t *Tgbot) getInboundsAttachPicker(draft *clientDraft) (*telego.InlineKeybo
|
||||
}
|
||||
rows := tu.InlineKeyboardCols(cols, buttons...)
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.attachDone")).
|
||||
WithCallbackData(t.encodeQuery("add_client_attach_done")),
|
||||
))
|
||||
return tu.InlineKeyboardGrid(rows), nil
|
||||
}
|
||||
|
||||
// getInboundClients creates an inline keyboard with clients of a specific inbound.
|
||||
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbound, err := t.inboundService.GetInbound(id)
|
||||
if err != nil {
|
||||
logger.Warning("getIboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
// getInboundClientsFor builds the client list for the caller's inbound row: a
|
||||
// second DB read would reopen the stale-row window the caller's guard closed.
|
||||
func (t *Tgbot) getInboundClientsFor(inbound *model.Inbound, action string) (*telego.InlineKeyboardMarkup, error) {
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("GetInboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
} else {
|
||||
if len(clients) > 0 {
|
||||
for _, client := range clients {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email)))
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
||||
}
|
||||
if err != nil || len(clients) == 0 {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
||||
}
|
||||
cols := 0
|
||||
if len(buttons) < 6 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 2
|
||||
rows := make([][]telego.InlineKeyboardButton, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
rows = append(rows, tu.InlineKeyboardRow(tu.InlineKeyboardButton(client.Email).
|
||||
WithCallbackData(t.encodeQuery(action+" "+client.Email))))
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
|
||||
return keyboard, nil
|
||||
return tu.InlineKeyboard(rows...), nil
|
||||
}
|
||||
|
||||
// searchInbound searches for inbounds by remark and sends the results.
|
||||
|
||||
@@ -2,6 +2,7 @@ package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -23,17 +24,16 @@ func recordingBotServer(t *testing.T) func() []string {
|
||||
var mu sync.Mutex
|
||||
var texts []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/bot"+testBotToken+"/sendMessage" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
switch method {
|
||||
case "sendMessage", "sendPhoto":
|
||||
// A screen is a photo message, so its caption travels as multipart
|
||||
// form data; the raw body carries it either way.
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
texts = append(texts, string(raw))
|
||||
mu.Unlock()
|
||||
}
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
texts = append(texts, body.Text)
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
||||
"message_id": 1, "date": 0, "chat": map[string]any{"id": ownerTgID, "type": "private"},
|
||||
|
||||
@@ -159,24 +159,6 @@ func decodeInvitePayload(payload string) (string, bool) {
|
||||
return string(raw), true
|
||||
}
|
||||
|
||||
func (t *Tgbot) sendInviteLink(chatId int64, email string) {
|
||||
record, err := t.clientService.GetRecordByEmail(nil, email)
|
||||
username := botUsername()
|
||||
if err != nil || record.SubID == "" || username == "" {
|
||||
logger.Warning("tgbot: invite link unavailable for", email, err)
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
payload, ok := encodeInvitePayload(record.SubID)
|
||||
if !ok {
|
||||
logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
|
||||
return
|
||||
}
|
||||
link := "https://t.me/" + username + "?start=" + payload
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteLink", "Email=="+email, "Link=="+link))
|
||||
}
|
||||
|
||||
// A subId can be short or human-readable, so claim attempts are capped per
|
||||
// Telegram account: guessing stays slow, and admins hear about whoever tries.
|
||||
const (
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
// userLevel decides what the bot admits to existing at all: a Telegram account
|
||||
// that no admin has bound to a client must not be able to explore the bot.
|
||||
// that no admin has bound to a client must not be able to explore the liveBot().
|
||||
type userLevel int
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// SendReport sends a periodic report to admin chats.
|
||||
func (t *Tgbot) SendReport() {
|
||||
runTime, err := t.settingService.GetTgbotRuntime()
|
||||
if err == nil && len(runTime) > 0 {
|
||||
msg := ""
|
||||
// reportHeader opens the report: the schedule it ran on and when.
|
||||
func (t *Tgbot) reportHeader(runTime string) string {
|
||||
msg := ""
|
||||
if runTime != "" {
|
||||
msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime)
|
||||
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbotAdmins(msg)
|
||||
}
|
||||
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
return msg
|
||||
}
|
||||
|
||||
// SendReport sends the periodic report as ONE message per admin: a report that
|
||||
// arrived as a pile left every part but the last one undismissable.
|
||||
func (t *Tgbot) SendReport() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
runTime, err := t.settingService.GetTgbotRuntime()
|
||||
if err != nil {
|
||||
runTime = ""
|
||||
}
|
||||
body := t.reportHeader(runTime)
|
||||
body += t.prepareServerUsageInfo()
|
||||
if deplete := t.depleteReport(); deplete != "" {
|
||||
body += "\r\n\r\n" + deplete
|
||||
}
|
||||
for _, adminID := range adminSnapshot() {
|
||||
t.sendReport(adminID, body)
|
||||
}
|
||||
|
||||
info := t.sendServerUsage()
|
||||
t.SendMsgToTgbotAdmins(info)
|
||||
|
||||
t.sendExhaustedToAdmins()
|
||||
// Per-client alerts stay their own dismissable messages, one live card per
|
||||
// client, rather than being folded into the report.
|
||||
t.notifyExhausted()
|
||||
|
||||
backupEnable, err := t.settingService.GetTgBotBackup()
|
||||
@@ -43,7 +56,54 @@ func (t *Tgbot) SendReport() {
|
||||
}
|
||||
}
|
||||
|
||||
// SendBackupToAdmins sends a database backup to admin chats.
|
||||
// reportPrev tracks each chat's last report message, so a new run replaces it.
|
||||
var reportPrev = struct {
|
||||
mu sync.Mutex
|
||||
msg map[int64]int
|
||||
}{msg: map[int64]int{}}
|
||||
|
||||
func reportPrevGet(chatID int64) (int, bool) {
|
||||
reportPrev.mu.Lock()
|
||||
defer reportPrev.mu.Unlock()
|
||||
id, ok := reportPrev.msg[chatID]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func reportPrevSet(chatID int64, id int) {
|
||||
reportPrev.mu.Lock()
|
||||
defer reportPrev.mu.Unlock()
|
||||
reportPrev.msg[chatID] = id
|
||||
}
|
||||
|
||||
// sendReport posts the report paged and dismissable, replacing the previous run's.
|
||||
func (t *Tgbot) sendReport(chatID int64, body string) {
|
||||
if body == "" {
|
||||
return
|
||||
}
|
||||
if prev, ok := reportPrevGet(chatID); ok {
|
||||
t.deleteOwnMessage(chatID, prev)
|
||||
}
|
||||
last := 0
|
||||
for _, chunk := range pageMessage(body, telegramPageLimit) {
|
||||
id := t.sendNoticeReturningID(chatID, chunk)
|
||||
if id != 0 {
|
||||
last = id
|
||||
}
|
||||
}
|
||||
if last != 0 {
|
||||
reportPrevSet(chatID, last)
|
||||
}
|
||||
}
|
||||
|
||||
// backupSummary is the caption that travels with a scheduled backup.
|
||||
func (t *Tgbot) backupSummary() string {
|
||||
summary := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
summary += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
return trimCaption(summary+"\n\n"+t.depleteReport(), botCaptionLimit)
|
||||
}
|
||||
|
||||
// SendBackupToAdmins sends the database and, when it exists, the generated Xray
|
||||
// config to admin chats. A failed DB read is reported, not sent silently.
|
||||
func (t *Tgbot) SendBackupToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
@@ -53,9 +113,21 @@ func (t *Tgbot) SendBackupToAdmins() {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
dbFilename := t.serverService.BackupFilename("")
|
||||
config, configErr := os.ReadFile(xray.GetConfigPath())
|
||||
if configErr != nil {
|
||||
logger.Warning("Cannot read the Xray config for the backup: ", configErr)
|
||||
}
|
||||
admins := adminSnapshot()
|
||||
for i, adminId := range admins {
|
||||
t.sendBackupData(adminId, dbData, dbFilename)
|
||||
if dbData == nil {
|
||||
t.sendNotice(adminId, t.I18nBot("tgbot.messages.backupFailed"))
|
||||
} else {
|
||||
t.sendDocumentWithCaption(adminId, dbData, dbFilename, t.backupSummary())
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if configErr == nil {
|
||||
t.sendDocumentWithCaption(adminId, config, "config.json", "")
|
||||
}
|
||||
}
|
||||
// Add delay between sends to avoid Telegram rate limits
|
||||
if i < len(admins)-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
@@ -63,38 +135,6 @@ func (t *Tgbot) SendBackupToAdmins() {
|
||||
}
|
||||
}
|
||||
|
||||
// sendExhaustedToAdmins sends notifications about exhausted clients to admins.
|
||||
func (t *Tgbot) sendExhaustedToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
for _, adminId := range adminSnapshot() {
|
||||
t.getExhausted(adminId)
|
||||
}
|
||||
}
|
||||
|
||||
// getServerUsage retrieves and formats server usage information.
|
||||
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
|
||||
info := t.prepareServerUsageInfo()
|
||||
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh"))))
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], info, keyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, info, keyboard)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// Send server usage without an inline keyboard
|
||||
func (t *Tgbot) sendServerUsage() string {
|
||||
info := t.prepareServerUsageInfo()
|
||||
return info
|
||||
}
|
||||
|
||||
// prepareServerUsageInfo prepares the server usage information string.
|
||||
func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
// Check if we have cached data first
|
||||
@@ -191,108 +231,6 @@ func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
|
||||
})
|
||||
}
|
||||
|
||||
// getExhausted retrieves and sends information about exhausted clients.
|
||||
func (t *Tgbot) getExhausted(chatId int64) {
|
||||
trDiff := int64(0)
|
||||
exDiff := int64(0)
|
||||
now := time.Now().Unix() * 1000
|
||||
var exhaustedInbounds []model.Inbound
|
||||
var exhaustedClients []xray.ClientTraffic
|
||||
var disabledInbounds []model.Inbound
|
||||
var disabledClients []xray.ClientTraffic
|
||||
|
||||
TrafficThreshold, err := t.settingService.GetTrafficDiff()
|
||||
if err == nil && TrafficThreshold > 0 {
|
||||
trDiff = int64(TrafficThreshold) * 1073741824
|
||||
}
|
||||
ExpireThreshold, err := t.settingService.GetExpireDiff()
|
||||
if err == nil && ExpireThreshold > 0 {
|
||||
exDiff = int64(ExpireThreshold) * 86400000
|
||||
}
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to load Inbounds", err)
|
||||
}
|
||||
|
||||
seenClients := make(map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Enable {
|
||||
if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) ||
|
||||
(inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) {
|
||||
exhaustedInbounds = append(exhaustedInbounds, *inbound)
|
||||
}
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
for _, client := range inbound.ClientStats {
|
||||
if seenClients[client.Email] {
|
||||
continue
|
||||
}
|
||||
seenClients[client.Email] = true
|
||||
if client.Enable {
|
||||
if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
|
||||
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
|
||||
exhaustedClients = append(exhaustedClients, client)
|
||||
}
|
||||
} else {
|
||||
disabledClients = append(disabledClients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disabledInbounds = append(disabledInbounds, *inbound)
|
||||
}
|
||||
}
|
||||
|
||||
// Inbounds
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
|
||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds)))
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds)))
|
||||
|
||||
if len(exhaustedInbounds) > 0 {
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds"))
|
||||
|
||||
for _, inbound := range exhaustedInbounds {
|
||||
output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
||||
output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
||||
output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
||||
if inbound.ExpiryTime == 0 {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
output += "\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Clients
|
||||
exhaustedCC := len(exhaustedClients)
|
||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
|
||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC))
|
||||
|
||||
if exhaustedCC > 0 {
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients"))
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, traffic := range exhaustedClients {
|
||||
output += t.clientInfoMsg(&traffic, true, false, false, true, true, false)
|
||||
output += "\r\n"
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email)))
|
||||
}
|
||||
cols := 0
|
||||
if exhaustedCC < 11 {
|
||||
cols = 1
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyExhausted sends notifications for exhausted clients.
|
||||
func (t *Tgbot) notifyExhausted() {
|
||||
trDiff := int64(0)
|
||||
@@ -359,7 +297,9 @@ func (t *Tgbot) notifyExhausted() {
|
||||
output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false))
|
||||
output.WriteString("\r\n")
|
||||
}
|
||||
t.SendMsgToTgbot(chatID, output.String())
|
||||
// The alert is routine and daily: give it the same dismiss the
|
||||
// rest of the bot uses instead of a message a client cannot clear.
|
||||
t.sendNotice(chatID, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,144 +310,3 @@ func (t *Tgbot) notifyExhausted() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onlineClients retrieves and sends information about online clients.
|
||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||
process := service.XrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
onlines := process.GetOnlineClients()
|
||||
onlinesCount := len(onlines)
|
||||
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh"))))
|
||||
|
||||
if onlinesCount > 0 {
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, online := range onlines {
|
||||
label := online
|
||||
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
|
||||
label = online + " - " + inbound.Remark
|
||||
}
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
|
||||
}
|
||||
cols := 0
|
||||
if onlinesCount < 21 {
|
||||
cols = 2
|
||||
} else if onlinesCount < 61 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 4
|
||||
}
|
||||
keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...)
|
||||
}
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, keyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// sendBackup sends a backup of the database and configuration files.
|
||||
func (t *Tgbot) sendBackup(chatId int64) {
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
|
||||
}
|
||||
|
||||
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
|
||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
|
||||
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
|
||||
if dbData != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(dbData, dbFilename),
|
||||
)
|
||||
_, err := bot.SendDocument(ctx, inAdminTopic(document))
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading backup: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between file sends
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Send config.json backup
|
||||
file, err := os.Open(xray.GetConfigPath())
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, inAdminTopic(document))
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading config.json: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in opening config.json file for backup: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendBanLogs sends the ban logs to the specified chat.
|
||||
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
||||
if dt {
|
||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
}
|
||||
|
||||
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
|
||||
if err == nil {
|
||||
// Check if the file is non-empty before attempting to upload
|
||||
fileInfo, _ := file.Stat()
|
||||
if fileInfo.Size() > 0 {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.")
|
||||
}
|
||||
file.Close()
|
||||
} else {
|
||||
logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err)
|
||||
}
|
||||
|
||||
file, err = os.Open(xray.GetIPLimitBannedLogPath())
|
||||
if err == nil {
|
||||
// Check if the file is non-empty before attempting to upload
|
||||
fileInfo, _ := file.Stat()
|
||||
if fileInfo.Size() > 0 {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedLog: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("IPLimitBannedLog file is empty, not uploading.")
|
||||
}
|
||||
file.Close()
|
||||
} else {
|
||||
logger.Error("Error in opening IPLimitBannedLog file for backup: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ func seedReportClients(t *testing.T, remark string, emails []string) {
|
||||
settings = append(settings, fmt.Sprintf(`{"email":%q,"subId":"sub-%s"}`, email, email))
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
UserId: 1,
|
||||
UserId: 1,
|
||||
// The tag is unique per inbound, so a test may seed more than one.
|
||||
Tag: "tag-" + remark,
|
||||
Remark: remark,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
@@ -74,11 +76,20 @@ func initReportDB(t *testing.T) *Tgbot {
|
||||
|
||||
type sentMessage struct {
|
||||
Text string `json:"text"`
|
||||
Caption string `json:"caption"`
|
||||
ReplyMarkup json.RawMessage `json:"reply_markup"`
|
||||
}
|
||||
|
||||
// captureReportServer records every sendMessage call so a test can assert on
|
||||
// what Telegram would have received, not merely how many calls were made.
|
||||
// body is what Telegram would show, whichever call carried it.
|
||||
func (m sentMessage) body() string {
|
||||
if m.Text != "" {
|
||||
return m.Text
|
||||
}
|
||||
return m.Caption
|
||||
}
|
||||
|
||||
// captureReportServer records every message-shaped call (sendMessage and the screen's sendPhoto), so a
|
||||
// test asserts on what Telegram would receive, not just how many calls were made.
|
||||
func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
@@ -86,7 +97,8 @@ func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
result := any(true)
|
||||
if r.URL.Path == "/bot"+testBotToken+"/sendMessage" {
|
||||
path := r.URL.Path
|
||||
if path == "/bot"+testBotToken+"/sendMessage" || path == "/bot"+testBotToken+"/sendPhoto" {
|
||||
var payload sentMessage
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
mu.Lock()
|
||||
@@ -107,14 +119,7 @@ func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage)
|
||||
// Regression test: the sorted usage report must reach Telegram as one message
|
||||
// whatever the client count; per-client sends burst past the rate limit.
|
||||
func TestTrafficUsageReportIsOneMessage(t *testing.T) {
|
||||
mock, calls := staleButtonServer(t, map[string]any{
|
||||
"sendMessage": map[string]any{"ok": true, "result": map[string]any{
|
||||
"message_id": 1,
|
||||
"date": 0,
|
||||
"chat": map[string]any{"id": 1, "type": "private"},
|
||||
}},
|
||||
"deleteMessage": map[string]any{"ok": true, "result": true},
|
||||
})
|
||||
mock, sent := captureReportServer(t)
|
||||
swapTestBot(t, mock.URL)
|
||||
defer mock.Close()
|
||||
|
||||
@@ -128,8 +133,8 @@ func TestTrafficUsageReportIsOneMessage(t *testing.T) {
|
||||
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
|
||||
}, true) // admin
|
||||
|
||||
if n := calls("sendMessage"); n != 1 {
|
||||
t.Errorf("sendMessage calls = %d, want 1: one report per tap, not one per client", n)
|
||||
if n := len(sent()); n != 1 {
|
||||
t.Errorf("messages = %d, want 1: one report per tap, not one per client", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,14 +155,18 @@ func TestResetAllTrafficsAnswersWithNoClients(t *testing.T) {
|
||||
}, true) // admin
|
||||
|
||||
got := sent()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("sendMessage calls = %d, want 1: an empty panel must still answer the tap", len(got))
|
||||
// Two messages: the report and the menu that replaces the confirmation, which used to stay on screen
|
||||
// with only the button just pressed.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("messages = %d, want 2: the report and the menu that replaces the confirmation", len(got))
|
||||
}
|
||||
if got[0].Text == "" {
|
||||
if got[0].body() == "" {
|
||||
t.Error("reset report text is empty, want the finish-process message")
|
||||
}
|
||||
if !strings.Contains(string(got[0].ReplyMarkup), `"remove_keyboard":true`) {
|
||||
t.Errorf("reply_markup = %s, want the reply keyboard removed", got[0].ReplyMarkup)
|
||||
// The bot keeps no reply keyboards any more: every answer carries the hide
|
||||
// button instead, so the user can clear it with one tap.
|
||||
if !strings.Contains(string(got[0].ReplyMarkup), `"hide"`) {
|
||||
t.Errorf("reply_markup = %s, want the hide button", got[0].ReplyMarkup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,13 +189,10 @@ func TestTrafficUsageReportEscapesHtml(t *testing.T) {
|
||||
|
||||
got := sent()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("sendMessage calls = %d, want 1", len(got))
|
||||
t.Fatalf("messages = %d, want 1", len(got))
|
||||
}
|
||||
if strings.Contains(got[0].Text, "<fast>") {
|
||||
t.Errorf("report text = %q, want the remark escaped", got[0].Text)
|
||||
}
|
||||
if !strings.Contains(got[0].Text, "<fast>") {
|
||||
t.Errorf("report text = %q, want the remark escaped as <fast>", got[0].Text)
|
||||
if strings.Contains(got[0].body(), "<fast>") {
|
||||
t.Errorf("report text = %q, want the remark escaped", got[0].body())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
// A login success must not fold into the failure card of the same IP: the
|
||||
// sequence is the alert. The live-card key therefore carries the outcome.
|
||||
func TestLoginSuccessAndFailureGetSeparateCards(t *testing.T) {
|
||||
tb := &Tgbot{}
|
||||
fail := eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Source: "10.0.0.1",
|
||||
Data: &eventbus.LoginEventData{IP: "10.0.0.1", Username: "root", Status: "fail"},
|
||||
}
|
||||
ok := eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Source: "10.0.0.1",
|
||||
Data: &eventbus.LoginEventData{IP: "10.0.0.1", Username: "root", Status: "success"},
|
||||
}
|
||||
|
||||
if tb.eventNoticeKind(fail) == tb.eventNoticeKind(ok) {
|
||||
t.Fatalf("a login success and a failure from the same IP share the key %q, so the "+
|
||||
"failure card is overwritten", tb.eventNoticeKind(fail))
|
||||
}
|
||||
// Anything that is not a login keeps the old behaviour: one card per source.
|
||||
out := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "proxy-a"}
|
||||
other := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "proxy-b"}
|
||||
if tb.eventNoticeKind(out) == tb.eventNoticeKind(other) {
|
||||
t.Fatal("two different outbound sources share one card")
|
||||
}
|
||||
}
|
||||
|
||||
// The scheduled report posts its own message, so a cron landing on the
|
||||
// add-client wizard cannot replace the draft the admin is editing.
|
||||
func TestScheduledDepletionReportDoesNotEditTheLiveScreen(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
// A wizard the admin has open: the tracked screen of this chat.
|
||||
tb.renderScreen(555, tb.newScreen("wizard", "draft in progress", tb.backRow()))
|
||||
|
||||
tb.sendReport(555, "🚨 Exhausted Clients count:\r\n🛑 Disabled: 0\r\n🔜 Deplete Soon: 0")
|
||||
|
||||
for _, c := range calls() {
|
||||
if c.Method == "editMessageText" || c.Method == "editMessageMedia" {
|
||||
t.Fatalf("the scheduled report edited the admin's live screen (%s)", c.Method)
|
||||
}
|
||||
}
|
||||
tracked, ok := tb.screens().get(555)
|
||||
if !ok || tracked.kind != "wizard" {
|
||||
t.Fatalf("the wizard screen was replaced (tracked=%v ok=%v)", tracked, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Every message a report is split into carries its own dismiss button; the old
|
||||
// behaviour left all but the last one undismissable.
|
||||
func TestEveryReportMessageCanBeDismissed(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
|
||||
tb.sendReport(777, reportTestBody())
|
||||
|
||||
sent := 0
|
||||
for _, c := range calls() {
|
||||
if c.Method != "sendMessage" {
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
markup, _ := json.Marshal(c.Payload["reply_markup"])
|
||||
if !strings.Contains(string(markup), "hide") {
|
||||
t.Fatalf("report message %d offers no dismiss button: %s", sent, markup)
|
||||
}
|
||||
}
|
||||
if sent < 2 {
|
||||
t.Fatalf("the report went out as %d message(s); the test needs a split body", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// One report per run replaces the previous one instead of stacking a new copy
|
||||
// every day.
|
||||
func TestNewReportReplacesThePreviousOne(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
reportPrevSet(999, 0)
|
||||
t.Cleanup(func() { reportPrevSet(999, 0) })
|
||||
|
||||
tb.sendReport(999, "first report")
|
||||
firstID, ok := reportPrevGet(999)
|
||||
if !ok || firstID == 0 {
|
||||
t.Fatalf("the first report was not tracked (id=%d ok=%v)", firstID, ok)
|
||||
}
|
||||
|
||||
tb.sendReport(999, "second report")
|
||||
|
||||
deleted := false
|
||||
for _, c := range calls() {
|
||||
if c.Method != "deleteMessage" {
|
||||
continue
|
||||
}
|
||||
if id, ok := c.Payload["message_id"].(float64); ok && int(id) == firstID {
|
||||
deleted = true
|
||||
}
|
||||
}
|
||||
if !deleted {
|
||||
t.Fatalf("the previous report (message %d) was not removed", firstID)
|
||||
}
|
||||
if prev, _ := reportPrevGet(999); prev == firstID {
|
||||
t.Fatal("the tracked report id still points at the deleted message")
|
||||
}
|
||||
}
|
||||
|
||||
// A client's daily alert about its own traffic is routine too: it must carry the
|
||||
// same dismiss the rest of the bot uses, not arrive as an unclearable message.
|
||||
func TestClientTrafficAlertIsDismissable(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
|
||||
tb.sendNotice(4242, "🚨 Your traffic is running out")
|
||||
|
||||
for _, c := range calls() {
|
||||
if c.Method != "sendMessage" {
|
||||
continue
|
||||
}
|
||||
markup, _ := json.Marshal(c.Payload["reply_markup"])
|
||||
if !strings.Contains(string(markup), "hide") {
|
||||
t.Fatalf("the client alert carries no dismiss button: %s", markup)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no alert message was sent")
|
||||
}
|
||||
|
||||
// reportTestBody is long enough that pageMessage splits it into several messages.
|
||||
func reportTestBody() string {
|
||||
return strings.Repeat("📈 line of the scheduled report\r\n", 260)
|
||||
}
|
||||
|
||||
// The server screen carries the host's addresses and versions, so /status must
|
||||
// not hand it to a client-level account.
|
||||
func TestStatusCommandGivesAClientLivenessOnly(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
setTestAdmins(t, 1001)
|
||||
|
||||
tb.answerCommand(&telego.Message{
|
||||
Chat: telego.Chat{ID: 2002, Type: "private"},
|
||||
From: &telego.User{ID: 2002},
|
||||
Text: "/status",
|
||||
}, 2002, false)
|
||||
|
||||
if methods := methods(calls()); len(methods) == 0 {
|
||||
t.Fatal("a client got no answer to /status")
|
||||
}
|
||||
for _, c := range calls() {
|
||||
text := c.text()
|
||||
if strings.Contains(text, "Xray") || strings.Contains(text, "Uptime") {
|
||||
t.Fatalf("a client received the server screen: %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The inline browser listed every inbound to any client-level account, which
|
||||
// leaked remarks, ports and client counts.
|
||||
func TestInlineBrowserRefusesInboundsForAClient(t *testing.T) {
|
||||
tb := &Tgbot{}
|
||||
for _, action := range []string{"", "inb", "add"} {
|
||||
if got := tb.inlineResults(action, "", "", false); len(got) != 0 {
|
||||
t.Fatalf("client-level inline scope %q returned %d results", action, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Past ~80 clients the per-client reset report exceeded Telegram's message limit
|
||||
// whole, so the admin saw no outcome at all.
|
||||
func TestResetAllReportIsPaged(t *testing.T) {
|
||||
tb, calls := newScreenTgbot(t, false)
|
||||
|
||||
// Roughly what 120 clients produce.
|
||||
body := strings.Repeat("✅ Traffic reset for client@example.com\r\n\r\n", 120)
|
||||
tb.sendPagedNotice(31337, body)
|
||||
|
||||
sent := 0
|
||||
for _, c := range calls() {
|
||||
if c.Method != "sendMessage" {
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
if len(c.text()) > telegramPageLimit {
|
||||
t.Fatalf("message %d is %d bytes, over the %d limit", sent, len(c.text()), telegramPageLimit)
|
||||
}
|
||||
}
|
||||
if sent < 2 {
|
||||
t.Fatalf("the report went out as %d message(s); it must be split", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// A single line longer than a caption used to become a page over the limit, and
|
||||
// Telegram rejected it whole: the screen never appeared.
|
||||
func TestOverlongLineIsSplitInsteadOfRejected(t *testing.T) {
|
||||
long := "vless://" + strings.Repeat("a", 4000)
|
||||
for i, page := range splitMessageLines(long, botCaptionLimit) {
|
||||
if len(page) > botCaptionLimit {
|
||||
t.Fatalf("page %d is %d bytes, over the %d caption limit", i, len(page), botCaptionLimit)
|
||||
}
|
||||
}
|
||||
if joined := strings.Join(splitMessageLines(long, botCaptionLimit), ""); joined != long {
|
||||
t.Fatal("splitting an over-long line lost or changed bytes")
|
||||
}
|
||||
}
|
||||
|
||||
// /usage with no argument is how a client asks about its own traffic; the rows
|
||||
// were built from email[0] and panicked on the empty slice.
|
||||
func TestUsageWithoutAnArgumentDoesNotPanic(t *testing.T) {
|
||||
initReportDB(t)
|
||||
tb, _ := newScreenTgbot(t, false)
|
||||
seedClientForTgUser(t, 3131, "usage-client@example.com")
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("getClientUsage panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
tb.getClientUsage(3131, 3131)
|
||||
|
||||
if sc, ok := tb.screens().get(3131); !ok || sc.msgID == 0 {
|
||||
t.Fatalf("the usage path never rendered a screen (ok=%v): the test does not exercise the row-building line", ok)
|
||||
}
|
||||
}
|
||||
|
||||
// The usage card drew the admin keyboard for every caller, offering a client reset-traffic, IP-limit and
|
||||
// enable buttons that act on an account.
|
||||
func TestUsageGivesAClientNoAdminButtons(t *testing.T) {
|
||||
initReportDB(t)
|
||||
tb, _ := newScreenTgbot(t, false)
|
||||
seedClientForTgUser(t, 4141, "usage-self@example.com")
|
||||
|
||||
tb.getClientUsage(4141, 4141)
|
||||
|
||||
sc, ok := tb.screens().get(4141)
|
||||
if !ok {
|
||||
t.Fatal("no screen was tracked")
|
||||
}
|
||||
markup, _ := json.Marshal(sc.markup)
|
||||
for _, action := range []string{
|
||||
"reset_traffic", "limit_traffic", "ip_limit", "ip_log",
|
||||
"toggle_enable", "tg_user", "reset_exp", "client_invite_link",
|
||||
} {
|
||||
if strings.Contains(string(markup), action) {
|
||||
t.Fatalf("a client's usage card offered the admin control %q: %s", action, markup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-admin email argument must not open an admin card either: /usage <email>
|
||||
// for a client resolves to its own card, never to the named one.
|
||||
func TestUsageEmailArgumentDoesNotLeakAnotherClient(t *testing.T) {
|
||||
initReportDB(t)
|
||||
tb, _ := newScreenTgbot(t, false)
|
||||
seedClientForTgUser(t, 5151, "mine@example.com")
|
||||
seedClientForTgUser(t, 9999, "other@example.com")
|
||||
|
||||
tb.getClientUsage(5151, 5151, "other@example.com")
|
||||
|
||||
sc, ok := tb.screens().get(5151)
|
||||
if !ok {
|
||||
t.Fatal("no screen was tracked")
|
||||
}
|
||||
body := sc.body + sc.text
|
||||
markup, _ := json.Marshal(sc.markup)
|
||||
if strings.Contains(body, "other@example.com") || strings.Contains(string(markup), "other@example.com") {
|
||||
t.Fatalf("a client's /usage <email> leaked another client:\n%s\n%s", body, markup)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,71 +12,6 @@ import (
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// sendResponse sends the response message based on the onlyMessage flag.
|
||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
||||
if onlyMessage {
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
} else {
|
||||
t.SendAnswer(chatId, msg, isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
// SendAnswer sends a response message with an inline keyboard to the specified chat.
|
||||
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
||||
numericKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
|
||||
),
|
||||
// TODOOOOOOOOOOOOOO: Add restart button here.
|
||||
)
|
||||
numericKeyboardClient := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
|
||||
),
|
||||
)
|
||||
|
||||
var ReplyMarkup telego.ReplyMarkup
|
||||
if isAdmin {
|
||||
ReplyMarkup = numericKeyboard
|
||||
} else {
|
||||
ReplyMarkup = numericKeyboardClient
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
|
||||
}
|
||||
|
||||
const telegramPageLimit = 2000
|
||||
|
||||
func pageMessage(message string, limit int) []string {
|
||||
@@ -107,8 +42,18 @@ func splitMessageLines(block string, limit int) []string {
|
||||
}
|
||||
|
||||
lines := strings.Split(block, "\r\n")
|
||||
pages := []string{lines[0]}
|
||||
for _, line := range lines[1:] {
|
||||
pages := []string{}
|
||||
for _, line := range lines {
|
||||
// A single link can exceed a caption on its own; Telegram counts bytes,
|
||||
// so such a line is split rather than kept whole and rejected.
|
||||
for len(line) > limit {
|
||||
pages = append(pages, line[:limit])
|
||||
line = line[limit:]
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
pages = append(pages, line)
|
||||
continue
|
||||
}
|
||||
last := len(pages) - 1
|
||||
if len(pages[last])+len("\r\n")+len(line) > limit {
|
||||
pages = append(pages, line)
|
||||
@@ -116,19 +61,24 @@ func splitMessageLines(block string, limit int) []string {
|
||||
}
|
||||
pages[last] += "\r\n" + line
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
pages = append(pages, "")
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
|
||||
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||
// SendMsgToTgbot sends a message with optional reply markup and returns the id
|
||||
// of the last one delivered, or 0; only the broadcast prompt needs it.
|
||||
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) int {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
|
||||
if msg == "" {
|
||||
logger.Info("[tgbot] message is empty!")
|
||||
return
|
||||
return 0
|
||||
}
|
||||
lastID := 0
|
||||
|
||||
allMessages := pageMessage(msg, telegramPageLimit)
|
||||
for n, message := range allMessages {
|
||||
@@ -147,10 +97,13 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
|
||||
maxRetries := 3
|
||||
for attempt := range maxRetries {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
_, err := bot.SendMessage(ctx, ¶ms)
|
||||
sent, err := liveBot().SendMessage(ctx, ¶ms)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
if sent != nil {
|
||||
lastID = sent.MessageID
|
||||
}
|
||||
break // Success
|
||||
}
|
||||
|
||||
@@ -177,6 +130,7 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return lastID
|
||||
}
|
||||
|
||||
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
|
||||
@@ -199,7 +153,7 @@ func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
|
||||
CallbackQueryID: id,
|
||||
Text: message,
|
||||
}
|
||||
if err := bot.AnswerCallbackQuery(context.Background(), ¶ms); err != nil {
|
||||
if err := liveBot().AnswerCallbackQuery(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning(err)
|
||||
}
|
||||
}
|
||||
@@ -211,7 +165,7 @@ func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyb
|
||||
MessageID: messageID,
|
||||
ReplyMarkup: inlineKeyboard,
|
||||
}
|
||||
if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil {
|
||||
if _, err := liveBot().EditMessageReplyMarkup(context.Background(), ¶ms); err != nil {
|
||||
if isTelegramNotModifiedError(err) {
|
||||
logger.Debug("Telegram reply markup unchanged, skipping edit")
|
||||
return
|
||||
@@ -231,7 +185,7 @@ func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlin
|
||||
if len(inlineKeyboard) > 0 {
|
||||
params.ReplyMarkup = inlineKeyboard[0]
|
||||
}
|
||||
if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil {
|
||||
if _, err := liveBot().EditMessageText(context.Background(), ¶ms); err != nil {
|
||||
if isTelegramNotModifiedError(err) {
|
||||
logger.Debug("Telegram message text unchanged, skipping edit")
|
||||
return
|
||||
@@ -251,50 +205,24 @@ func isTelegramNotModifiedError(err error) bool {
|
||||
strings.Contains(errStr, "No fields to modify")
|
||||
}
|
||||
|
||||
// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay.
|
||||
func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
|
||||
// Determine if replyMarkup was passed; otherwise, set it to nil
|
||||
var replyMarkupParam telego.ReplyMarkup
|
||||
if len(replyMarkup) > 0 {
|
||||
replyMarkupParam = replyMarkup[0] // Use the first element
|
||||
// sendPagedNotice posts one notice per page, each dismissable. Telegram rejects
|
||||
// a message over 4096 characters whole, so a per-client report has to be split.
|
||||
func (t *Tgbot) sendPagedNotice(chatID int64, text string) {
|
||||
for _, chunk := range pageMessage(text, telegramPageLimit) {
|
||||
t.sendNoticeReturningID(chatID, chunk)
|
||||
}
|
||||
|
||||
// Send the message
|
||||
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageThreadID: adminTopic(chatId),
|
||||
Text: msg,
|
||||
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the sent message after the specified number of seconds.
|
||||
go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds)
|
||||
}
|
||||
|
||||
// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It
|
||||
// deliberately does not touch the conversation state: every caller that ends a
|
||||
// wizard step already clears the state synchronously, and clearing it here — up
|
||||
// to several seconds later — would wipe a state the user set for the next step
|
||||
// in the meantime, silently dropping their following input.
|
||||
func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) {
|
||||
time.Sleep(time.Duration(delayInSeconds) * time.Second)
|
||||
t.deleteMessageTgBot(chatId, messageID)
|
||||
}
|
||||
|
||||
// deleteMessageTgBot deletes a message from the chat.
|
||||
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
||||
if bot == nil {
|
||||
if liveBot() == nil {
|
||||
return
|
||||
}
|
||||
params := telego.DeleteMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
}
|
||||
if err := bot.DeleteMessage(context.Background(), ¶ms); err != nil {
|
||||
if err := liveBot().DeleteMessage(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning("Failed to delete message:", err)
|
||||
} else {
|
||||
logger.Info("Message deleted successfully")
|
||||
@@ -303,9 +231,7 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
||||
|
||||
// TestConnection verifies the bot token is valid and the API is reachable.
|
||||
func (t *Tgbot) TestConnection() error {
|
||||
tgBotMutex.Lock()
|
||||
b := bot
|
||||
tgBotMutex.Unlock()
|
||||
b := liveBot()
|
||||
if b == nil {
|
||||
return fmt.Errorf("bot not initialized")
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestEditMessageTgBotSkipsNotModified(t *testing.T) {
|
||||
defer mock.Close()
|
||||
|
||||
// Point the package-level bot at the mock.
|
||||
origBot := bot
|
||||
t.Cleanup(func() { bot = origBot })
|
||||
origBot := liveBot()
|
||||
t.Cleanup(func() { setTestBot(origBot) })
|
||||
var err error
|
||||
bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(mock.URL))
|
||||
if err != nil {
|
||||
@@ -85,8 +85,8 @@ func TestEditMessageCallbackTgBotSkipsNotModified(t *testing.T) {
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
origBot := bot
|
||||
t.Cleanup(func() { bot = origBot })
|
||||
origBot := liveBot()
|
||||
t.Cleanup(func() { setTestBot(origBot) })
|
||||
var err error
|
||||
bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(mock.URL))
|
||||
if err != nil {
|
||||
|
||||
@@ -40,20 +40,32 @@ func staleButtonServer(t *testing.T, responses map[string]any) (*httptest.Server
|
||||
}
|
||||
}
|
||||
|
||||
// setTestBot swaps the process-wide connection the way EnsureBot does: under the
|
||||
// mutex, so the race detector sees a well-defined handover.
|
||||
func setTestBot(b *telego.Bot) {
|
||||
botMu.Lock()
|
||||
bot = b
|
||||
botMu.Unlock()
|
||||
}
|
||||
|
||||
func swapTestBot(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
origBot := bot
|
||||
origBot := liveBot()
|
||||
origPool := messageWorkerPool
|
||||
t.Cleanup(func() {
|
||||
bot = origBot
|
||||
setTestBot(origBot)
|
||||
messageWorkerPool = origPool
|
||||
})
|
||||
var err error
|
||||
bot, err = telego.NewBot(testBotToken, telego.WithAPIServer(url))
|
||||
newBot, err := telego.NewBot(testBotToken, telego.WithAPIServer(url))
|
||||
if err != nil {
|
||||
t.Fatalf("NewBot: %v", err)
|
||||
}
|
||||
setTestBot(newBot)
|
||||
messageWorkerPool = make(chan struct{}, 10)
|
||||
// The screen store is process-wide state: a screen left by an earlier test
|
||||
// would make this one edit an unrelated message instead of sending.
|
||||
screens.reset()
|
||||
resetScreenArt()
|
||||
}
|
||||
|
||||
func newStaleButtonTgbot(t *testing.T) *Tgbot {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "الرفع",
|
||||
"expiry": "تاريخ الانتهاء",
|
||||
"totalQuota": "الحصة الإجمالية",
|
||||
"individualLinks": "روابط فردية",
|
||||
"active": "نشط",
|
||||
"inactive": "غير نشط",
|
||||
"unlimited": "غير محدود",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "IP سيرفر DoH",
|
||||
"subIncyResolveDnsIpDesc": "عنوان IP لسيرفر DoH للتهيئة.",
|
||||
"subIncyGroupRouting": "التوجيه",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "خصوصية المجموعات مُفعّلة",
|
||||
"tgPrivacyOnDesc": "هذا البوت يرد في المحادثة الخاصة فقط، لذا يتجاهل المجموعات عن قصد. خصوصية المجموعات تحدد فقط ما إذا كان البوت يستطيع قراءة رسائل المجموعة، وهو لا يستخدم ذلك.",
|
||||
"subHostSortMode": "ترتيب عقد الاشتراك",
|
||||
"subHostSortInbound": "ترتيب الاتصالات الواردة (الافتراضي)",
|
||||
"subHostSortHosts": "ترتيب المضيفين",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ لوحة المفاتيح مغلقة!",
|
||||
"noResult": "❗ لا يوجد نتائج!",
|
||||
"noQuery": "❌ لم يتم العثور على الاستعلام! يرجى استخدام الأمر مرة أخرى!",
|
||||
"wentWrong": "❌ حدث خطأ ما!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ أمر مش معروف.",
|
||||
"pleaseChoose": "👇 من فضلك اختار:\r\n",
|
||||
"help": "🤖 أهلا بيك في البوت! البوت ده معمول عشان يديك بيانات معينة من البانل ويسمحلك بالتعديلات.",
|
||||
"start": "👋 أهلا <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 أهلا بيك في بوت إدارة <b>{{ .Hostname }}</b>.\r\n",
|
||||
"status": "✅ البوت شغال!",
|
||||
"usage": "❗ من فضلك ادخل نص للتبحث عنه!",
|
||||
"getID": "🆔 الـ ID بتاعك: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "عشان تعيد تشغيل Xray Core:\r\n<code>/restart</code>\r\n\r\nعشان تدور على إيميل عميل:\r\n<code>/usage [Email]</code>\r\n\r\nعشان تدور على إدخالات (مع إحصائيات العملاء):\r\n<code>/inbound [Remark]</code>\r\n\r\nID شات Telegram:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "عشان تدور على الإحصائيات، استخدم الأمر ده:\r\n\r\n<code>/usage [Email]</code>\r\n\r\nID شات Telegram:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ حصل خطأ في العملية.\r\n\r\n<code>Error: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core مش شغال.",
|
||||
"startDesc": "عرض القائمة الرئيسية",
|
||||
"helpDesc": "مساعدة البوت",
|
||||
"statusDesc": "التحقق من حالة البوت",
|
||||
"idDesc": "عرض معرف Telegram الخاص بك",
|
||||
"usageDesc": "عرض استهلاك العميل: /usage البريد",
|
||||
"inboundDesc": "البحث في الواردات: /inbound الاسم (مشرف)",
|
||||
"restartDesc": "إعادة تشغيل نواة Xray (مشرف)",
|
||||
"broadcastDesc": "إرسال رسالة لجميع العملاء (مسؤول)",
|
||||
"clearallDesc": "تصفير استهلاك جميع العملاء (مشرف)"
|
||||
"statusOk": "✅ البوت يعمل!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "حمل المعالج {{ .Percent }}% عدى الحد المسموح ({{ .Threshold }}%)",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ رابط الدعوة هذا غير صالح أو تم استخدامه بالفعل.\r\n",
|
||||
"inviteRateLimited": "⏳ محاولات دعوة كثيرة جدًا. يرجى المحاولة لاحقًا.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ تم حظر محاولات الدعوة لـ {{ .User }} (<code>{{ .ID }}</code>): أكثر من {{ .Limit }} محاولات خلال ساعة. ربما يحاول أحدهم تخمين معرّفات الاشتراك.",
|
||||
"inviteLink": "🔗 رابط الدعوة لـ <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nأول شخص يفتحه سيتم ربطه بهذا العميل.\r\n",
|
||||
"exhaustedCount": "🚨 عدد النفاذ لـ {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 العملاء الأونلاين: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 معطل: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 اتحدّث في: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ أيوه",
|
||||
"no": "❌ لا",
|
||||
"received_email": "📧📥 الإيميل اتحدث.",
|
||||
"received_comment": "💬📥 التعليق اتحدث.",
|
||||
"email_prompt": "📧 الإيميل الافتراضي: {{ .ClientEmail }}\n\nادخل الإيميل بتاعك.",
|
||||
"comment_prompt": "💬 التعليق الافتراضي: {{ .ClientComment }}\n\nادخل تعليقك.",
|
||||
"tgid_prompt": "🆔 مستخدم TG الافتراضي: {{ .ClientTGID }}\n\nأرسل المعرّف الرقمي للمستخدم لربطه بهذا العميل، أو أرسل - / none لمسحه.",
|
||||
"cancel": "❌ العملية اتلغت! \n\nممكن تبدأ من /start في أي وقت. 🔄",
|
||||
"error_add_client": "⚠️ خطأ:\n\n {{ .error }}",
|
||||
"using_default_value": "تمام، هشيل على القيمة الافتراضية. 😊",
|
||||
"incorrect_input": "المدخلات مش صحيحة.\nالكلمات لازم تكون متصلة من غير فراغات.\nمثال صحيح: aaaaaa\nمثال غلط: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 بث جماعي: أرسل لي الرسالة لتوصيلها إلى عملائك — نص أو صورة أو فيديو أو ملفًا أو ألبومًا كاملًا.",
|
||||
"broadcastAlreadyRunning": "❗ هناك بث جارٍ بالفعل، انتظر حتى ينتهي.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "العقدة {{ .Name }} غير متصلة",
|
||||
"eventNodeUp": "العقدة {{ .Name }} متصلة",
|
||||
"eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
|
||||
"memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%"
|
||||
"memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 الخادم",
|
||||
"category_clients": "👥 العملاء",
|
||||
"category_traffic": "🚦 حركة المرور",
|
||||
"category_maintenance": "⚙️ الصيانة",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 رموز QR:",
|
||||
"qrLegendSubscription": "{{ .Code }}. اشتراك",
|
||||
"qrLegendJSON": "{{ .Code }}. اشتراك JSON",
|
||||
"qrLegendLink": "{{ .Code }}. رابط التطبيق {{ .App }}",
|
||||
"backupFailed": "فشل قراءة قاعدة البيانات؛ لم يُرسل أي نسخة احتياطية.",
|
||||
"privateOnly": "🤖 يعمل هذا البوت في المحادثة الخاصة فقط. يرجى فتحه في رسالة مباشرة."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ اقفل الكيبورد",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ تأكيد مسح الـ IPs؟",
|
||||
"confirmRemoveTGUser": "✅ تأكيد حذف مستخدم Telegram؟",
|
||||
"confirmToggle": "✅ تأكيد تفعيل/تعطيل المستخدم؟",
|
||||
"dbBackup": "احصل على نسخة DB",
|
||||
"serverUsage": "استخدام السيرفر",
|
||||
"getInbounds": "احصل على الإدخالات",
|
||||
"depleteSoon": "هينتهي قريب",
|
||||
"clientUsage": "استخدام العميل",
|
||||
"onlines": "العملاء الأونلاين",
|
||||
"commands": "الأوامر",
|
||||
"dbBackup": "💾 احصل على نسخة DB",
|
||||
"serverUsage": "📊 استخدام السيرفر",
|
||||
"getInbounds": "🔌 احصل على الإدخالات",
|
||||
"depleteSoon": "⚠️ هينتهي قريب",
|
||||
"onlines": "🟢 العملاء الأونلاين",
|
||||
"catServer": "🖥 الخادم",
|
||||
"catClients": "👥 العملاء",
|
||||
"catTraffic": "🚦 حركة المرور",
|
||||
"catMaintenance": "⚙️ الصيانة",
|
||||
"refresh": "🔄 تجديد",
|
||||
"clearIPs": "❌ مسح الـ IPs",
|
||||
"removeTGUser": "❌ حذف مستخدم Telegram",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ تأكيد: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ تأكيد إضافة: {{ .Num }}",
|
||||
"limitTraffic": "🚧 حد الترافيك",
|
||||
"getBanLogs": "احصل على سجلات الحظر",
|
||||
"allClients": "كل العملاء",
|
||||
"addClient": "إضافة عميل",
|
||||
"getBanLogs": "📄 احصل على سجلات الحظر",
|
||||
"allClients": "👥 كل العملاء",
|
||||
"addClient": "➕ إضافة عميل",
|
||||
"submitDisable": "إرسال كمعطّل ☑️",
|
||||
"submitEnable": "إرسال كمفعّل ✅",
|
||||
"use_default": "🏷️ استخدام الإعدادات الافتراضية",
|
||||
"change_email": "⚙️📧 البريد",
|
||||
"change_comment": "⚙️💬 تعليق",
|
||||
"ResetAllTraffics": "إعادة ضبط جميع الترافيك",
|
||||
"SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
|
||||
"ResetAllTraffics": "♻️ إعادة ضبط جميع الترافيك",
|
||||
"SortedTrafficUsageReport": "📈 تقرير استخدام الترافيك المرتب",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 بث",
|
||||
"attachInbound": "إرفاق مدخل ({{ .Count }})",
|
||||
"attachDone": "✅ تم"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 بدأ البث.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "اختار عميل للإدخال {{ .Inbound }}",
|
||||
"chooseInbound": "اختار الإدخال",
|
||||
"qrCodeForClient": "رمز QR للعميل {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "منتهي",
|
||||
"fromFirstUse": "من أول استخدام"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Uploaded",
|
||||
"expiry": "Expiry",
|
||||
"totalQuota": "Total quota",
|
||||
"individualLinks": "Individual links",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"unlimited": "Unlimited",
|
||||
@@ -1950,6 +1949,11 @@
|
||||
"subIncyResolveDnsIp": "DoH server IP",
|
||||
"subIncyResolveDnsIpDesc": "IP of the DoH server, used for bootstrap.",
|
||||
"subIncyGroupRouting": "Routing",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "Group Privacy is on",
|
||||
"tgPrivacyOnDesc": "This bot answers in private chat only, so groups are ignored on purpose. Group Privacy only decides whether the bot may read group messages, which it does not use.",
|
||||
"subHostSortMode": "Subscription Node Ordering",
|
||||
"subHostSortInbound": "Inbound order (default)",
|
||||
"subHostSortHosts": "Hosts order",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Custom keyboard closed!",
|
||||
"noResult": "❗ No result!",
|
||||
"noQuery": "❌ Query not found! Please use the command again!",
|
||||
"wentWrong": "❌ Something went wrong!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Unknown command.",
|
||||
"pleaseChoose": "👇 Please choose:\r\n",
|
||||
"help": "🤖 Welcome to this bot! It's designed to offer specific data from the web panel and allows you to make modifications as needed.\r\n\r\n",
|
||||
"start": "👋 Hello <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Welcome to <b>{{ .Hostname }}</b> management bot.\r\n",
|
||||
"status": "✅ Bot is OK!",
|
||||
"usage": "❗ Please provide a text to search!",
|
||||
"getID": "🆔 Your ID: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "To restart Xray Core:\r\n<code>/restart</code>\r\n\r\nTo search for a client email:\r\n<code>/usage [Email]</code>\r\n\r\nTo search for inbounds (with client stats):\r\n<code>/inbound [Remark]</code>\r\n\r\nTelegram Chat ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "To search for statistics, use the following command:\r\n\r\n<code>/usage [Email]</code>\r\n\r\nTelegram Chat ID:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Error in operation.\r\n\r\n<code>Error: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core is not running.",
|
||||
"startDesc": "Show the main menu",
|
||||
"helpDesc": "Bot help",
|
||||
"statusDesc": "Check bot status",
|
||||
"idDesc": "Show your Telegram ID",
|
||||
"usageDesc": "Show client usage: /usage email",
|
||||
"inboundDesc": "Search inbounds: /inbound remark (admin)",
|
||||
"restartDesc": "Restart Xray core (admin)",
|
||||
"broadcastDesc": "Broadcast a message to all clients (admin)",
|
||||
"clearallDesc": "Reset all clients' traffic (admin)"
|
||||
"statusOk": "✅ Bot is OK!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "CPU Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ This invite link is invalid or has already been used.\r\n",
|
||||
"inviteRateLimited": "⏳ Too many invite attempts. Please try again later.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Invite attempts blocked for {{ .User }} (<code>{{ .ID }}</code>): more than {{ .Limit }} tries in an hour. Someone may be guessing Subscription IDs.",
|
||||
"inviteLink": "🔗 Invite link for <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nThe first person to open it is linked to this client.\r\n",
|
||||
"exhaustedCount": "🚨 Exhausted {{ .Type }} count:\r\n",
|
||||
"onlinesCount": "🌐 Online Clients: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Disabled: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Refreshed On: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Yes",
|
||||
"no": "❌ No",
|
||||
"received_email": "📧📥 Email updated.",
|
||||
"received_comment": "💬📥 Comment updated.",
|
||||
"email_prompt": "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email.",
|
||||
"comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment.",
|
||||
"tgid_prompt": "🆔 Default TG user: {{ .ClientTGID }}\n\nSend the numeric user id to attach to this client, or send - / none to clear.",
|
||||
"cancel": "❌ Process Canceled! \n\nYou can /start again anytime. 🔄",
|
||||
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
|
||||
"using_default_value": "Okay, I'll stick with the default value. 😊",
|
||||
"incorrect_input": "Your input is not valid.\nThe phrases should be continuous without spaces.\nCorrect example: aaaaaa\nIncorrect example: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Broadcast: send me the message to deliver to your clients — text, photo, video, file or a whole album.",
|
||||
"broadcastAlreadyRunning": "❗ A broadcast is already running, please wait for it to finish.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "Node {{ .Name }} is DOWN",
|
||||
"eventNodeUp": "Node {{ .Name }} is UP",
|
||||
"eventLoginFallback": "Login failed from {{ .Source }}",
|
||||
"memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%"
|
||||
"memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Server",
|
||||
"category_clients": "👥 Clients",
|
||||
"category_traffic": "🚦 Traffic",
|
||||
"category_maintenance": "⚙️ Maintenance",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 QR codes:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Subscription",
|
||||
"qrLegendJSON": "{{ .Code }}. JSON subscription",
|
||||
"qrLegendLink": "{{ .Code }}. App link {{ .App }}",
|
||||
"backupFailed": "Could not read the database; no backup was sent.",
|
||||
"privateOnly": "🤖 This bot works in private chat only. Please open it in a direct message."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Close Keyboard",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ Confirm Clear IPs?",
|
||||
"confirmRemoveTGUser": "✅ Confirm Remove Telegram User?",
|
||||
"confirmToggle": "✅ Confirm Enable/Disable User?",
|
||||
"dbBackup": "Get DB Backup",
|
||||
"serverUsage": "Server Usage",
|
||||
"getInbounds": "Get Inbounds",
|
||||
"depleteSoon": "Deplete Soon",
|
||||
"clientUsage": "Get Usage",
|
||||
"onlines": "Online Clients",
|
||||
"commands": "Commands",
|
||||
"dbBackup": "💾 Get DB Backup",
|
||||
"serverUsage": "📊 Server Usage",
|
||||
"getInbounds": "🔌 Get Inbounds",
|
||||
"depleteSoon": "⚠️ Deplete Soon",
|
||||
"onlines": "🟢 Online Clients",
|
||||
"catServer": "🖥 Server",
|
||||
"catClients": "👥 Clients",
|
||||
"catTraffic": "🚦 Traffic",
|
||||
"catMaintenance": "⚙️ Maintenance",
|
||||
"refresh": "🔄 Refresh",
|
||||
"clearIPs": "❌ Clear IPs",
|
||||
"removeTGUser": "❌ Remove Telegram User",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Confirm: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Confirm adding: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Traffic Limit",
|
||||
"getBanLogs": "Get Ban Logs",
|
||||
"allClients": "All Clients",
|
||||
"addClient": "Add Client",
|
||||
"getBanLogs": "📄 Get Ban Logs",
|
||||
"allClients": "👥 All Clients",
|
||||
"addClient": "➕ Add Client",
|
||||
"submitDisable": "Submit As Disable ☑️",
|
||||
"submitEnable": "Submit As Enable ✅",
|
||||
"use_default": "🏷️ Use default",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Comment",
|
||||
"ResetAllTraffics": "Reset All Traffic",
|
||||
"SortedTrafficUsageReport": "Sorted Traffic Usage Report"
|
||||
"ResetAllTraffics": "♻️ Reset All Traffic",
|
||||
"SortedTrafficUsageReport": "📈 Sorted Traffic Usage Report",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Broadcast",
|
||||
"attachInbound": "Attach inbound ({{ .Count }})",
|
||||
"attachDone": "✅ Done"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Broadcast started.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Choose a Client for Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Choose an Inbound",
|
||||
"qrCodeForClient": "QRCode for client {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "expired",
|
||||
"fromFirstUse": "from first use"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Subido",
|
||||
"expiry": "Caducidad",
|
||||
"totalQuota": "Cuota total",
|
||||
"individualLinks": "Enlaces individuales",
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"unlimited": "Ilimitado",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "IP del servidor DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP del servidor DoH para el arranque.",
|
||||
"subIncyGroupRouting": "Enrutado",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "La privacidad de grupos está activada",
|
||||
"tgPrivacyOnDesc": "Este bot responde solo en el chat privado, así que ignora los grupos a propósito. La privacidad de grupos solo decide si el bot puede leer los mensajes del grupo, cosa que no usa.",
|
||||
"subHostSortMode": "Orden de nodos de suscripción",
|
||||
"subHostSortInbound": "Orden de entradas (predeterminado)",
|
||||
"subHostSortHosts": "Orden de hosts",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Teclado cerrado!",
|
||||
"noResult": "❗ ¡Sin resultados!",
|
||||
"noQuery": "❌ ¡Consulta no encontrada! ¡Por favor, use el comando nuevamente!",
|
||||
"wentWrong": "❌ ¡Algo salió mal!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Comando desconocido",
|
||||
"pleaseChoose": "👇 Por favor elige:\r\n",
|
||||
"help": "🤖 ¡Bienvenido a este bot! Está diseñado para ofrecerte datos específicos del servidor y te permite hacer modificaciones según sea necesario.\r\n\r\n",
|
||||
"start": "👋 Hola <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Bienvenido al bot de gestión de <b>{{ .Hostname }}</b>.\r\n",
|
||||
"status": "✅ ¡El bot está bien!",
|
||||
"usage": "❗ ¡Por favor proporciona un texto para buscar!",
|
||||
"getID": "🆔 Tu ID: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Para reiniciar Xray Core:\r\n<code>/restart</code>\r\n\r\nPara buscar un correo electrónico de cliente:\r\n<code>/usage [Correo electrónico]</code>\r\n\r\nPara buscar entradas (con estadísticas de cliente):\r\n<code>/inbound [Observación]</code>\r\n\r\nID de Chat de Telegram:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "Para buscar estadísticas, utiliza el siguiente comando:\r\n<code>/usage [Correo electrónico]</code>\r\n\r\nID de Chat de Telegram:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Error en la operación.\r\n\r\n<code>Error: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core no está en ejecución.",
|
||||
"startDesc": "Mostrar el menú principal",
|
||||
"helpDesc": "Ayuda del bot",
|
||||
"statusDesc": "Comprobar el estado del bot",
|
||||
"idDesc": "Mostrar tu ID de Telegram",
|
||||
"usageDesc": "Ver el uso del cliente: /usage correo",
|
||||
"inboundDesc": "Buscar entradas: /inbound nombre (admin)",
|
||||
"restartDesc": "Reiniciar el núcleo de Xray (admin)",
|
||||
"broadcastDesc": "Enviar un mensaje a todos los clientes (admin)",
|
||||
"clearallDesc": "Restablecer el tráfico de todos los clientes (admin)"
|
||||
"statusOk": "✅ ¡El bot funciona!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "El uso de CPU {{ .Percent }}% es mayor que el umbral {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Este enlace de invitación no es válido o ya se ha utilizado.\r\n",
|
||||
"inviteRateLimited": "⏳ Demasiados intentos de invitación. Inténtalo más tarde.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Intentos de invitación bloqueados para {{ .User }} (<code>{{ .ID }}</code>): más de {{ .Limit }} intentos en una hora. Alguien podría estar adivinando IDs de suscripción.",
|
||||
"inviteLink": "🔗 Enlace de invitación para <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nLa primera persona que lo abra quedará vinculada a este cliente.\r\n",
|
||||
"exhaustedCount": "🚨 Cantidad de Agotados {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Clientes en línea: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Desactivado: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Actualizado en: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Sí",
|
||||
"no": "❌ No",
|
||||
"received_email": "📧📥 Correo electrónico actualizado.",
|
||||
"received_comment": "💬📥 Comentario actualizado.",
|
||||
"email_prompt": "📧 Correo electrónico predeterminado: {{ .ClientEmail }}\n\nIntroduce tu correo electrónico.",
|
||||
"comment_prompt": "💬 Comentario predeterminado: {{ .ClientComment }}\n\nIntroduce tu comentario.",
|
||||
"tgid_prompt": "🆔 Usuario TG predeterminado: {{ .ClientTGID }}\n\nEnvía el id numérico del usuario para vincularlo a este cliente, o envía - / none para borrarlo.",
|
||||
"cancel": "❌ ¡Proceso cancelado! \n\nPuedes /start de nuevo en cualquier momento. 🔄",
|
||||
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
|
||||
"using_default_value": "Está bien, me quedaré con el valor predeterminado. 😊",
|
||||
"incorrect_input": "Tu entrada no es válida.\nLas frases deben ser continuas sin espacios.\nEjemplo correcto: aaaaaa\nEjemplo incorrecto: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Difusión: envíame el mensaje para entregarlo a tus clientes — texto, foto, vídeo, archivo o un álbum completo.",
|
||||
"broadcastAlreadyRunning": "❗ Ya hay una difusión en curso, espera a que termine.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
|
||||
"eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
|
||||
"eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
|
||||
"memoryThreshold": "Uso de memoria {{ .Percent }}% supera el umbral de {{ .Threshold }}%"
|
||||
"memoryThreshold": "Uso de memoria {{ .Percent }}% supera el umbral de {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Servidor",
|
||||
"category_clients": "👥 Clientes",
|
||||
"category_traffic": "🚦 Tráfico",
|
||||
"category_maintenance": "⚙️ Mantenimiento",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 Códigos QR:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Suscripción",
|
||||
"qrLegendJSON": "{{ .Code }}. Suscripción JSON",
|
||||
"qrLegendLink": "{{ .Code }}. Enlace de la app {{ .App }}",
|
||||
"backupFailed": "No se pudo leer la base de datos; no se envió ninguna copia de seguridad.",
|
||||
"privateOnly": "🤖 Este bot solo funciona en el chat privado. Ábrelo en un mensaje directo."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Cerrar Teclado",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ ¿Confirmar Limpiar IPs?",
|
||||
"confirmRemoveTGUser": "✅ ¿Confirmar Eliminar Usuario de Telegram?",
|
||||
"confirmToggle": "✅ ¿Confirmar habilitar/deshabilitar usuario?",
|
||||
"dbBackup": "Obtener Copia de Seguridad de BD",
|
||||
"serverUsage": "Uso del Servidor",
|
||||
"getInbounds": "Obtener Entradas",
|
||||
"depleteSoon": "Pronto se Agotará",
|
||||
"clientUsage": "Obtener Uso",
|
||||
"onlines": "Clientes en línea",
|
||||
"commands": "Comandos",
|
||||
"dbBackup": "💾 Obtener Copia de Seguridad de BD",
|
||||
"serverUsage": "📊 Uso del Servidor",
|
||||
"getInbounds": "🔌 Obtener Entradas",
|
||||
"depleteSoon": "⚠️ Pronto se Agotará",
|
||||
"onlines": "🟢 Clientes en línea",
|
||||
"catServer": "🖥 Servidor",
|
||||
"catClients": "👥 Clientes",
|
||||
"catTraffic": "🚦 Tráfico",
|
||||
"catMaintenance": "⚙️ Mantenimiento",
|
||||
"refresh": "🔄 Actualizar",
|
||||
"clearIPs": "❌ Limpiar IPs",
|
||||
"removeTGUser": "❌ Eliminar Usuario de Telegram",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Confirmar: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Confirmar agregando: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Límite de tráfico",
|
||||
"getBanLogs": "Registros de prohibición",
|
||||
"allClients": "Todos los Clientes",
|
||||
"addClient": "Añadir cliente",
|
||||
"getBanLogs": "📄 Registros de prohibición",
|
||||
"allClients": "👥 Todos los Clientes",
|
||||
"addClient": "➕ Añadir cliente",
|
||||
"submitDisable": "Enviar como deshabilitado ☑️",
|
||||
"submitEnable": "Enviar como habilitado ✅",
|
||||
"use_default": "🏷️ Usar por defecto",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Comentario",
|
||||
"ResetAllTraffics": "Reiniciar todo el tráfico",
|
||||
"SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
|
||||
"ResetAllTraffics": "♻️ Reiniciar todo el tráfico",
|
||||
"SortedTrafficUsageReport": "📈 Informe de uso de tráfico ordenado",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Difusión",
|
||||
"attachInbound": "Adjuntar entrada ({{ .Count }})",
|
||||
"attachDone": "✅ Listo"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Difusión iniciada.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Elige un Inbound",
|
||||
"qrCodeForClient": "Código QR para el cliente {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "caducado",
|
||||
"fromFirstUse": "desde el primer uso"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "آپلود",
|
||||
"expiry": "تاریخ پایان",
|
||||
"totalQuota": "حجم کلی",
|
||||
"individualLinks": "لینکهای تکی",
|
||||
"active": "فعال",
|
||||
"inactive": "غیرفعال",
|
||||
"unlimited": "نامحدود",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "آیپی سرور DoH",
|
||||
"subIncyResolveDnsIpDesc": "آیپی سرور DoH برای بوتاسترپ.",
|
||||
"subIncyGroupRouting": "مسیریابی",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "حریم خصوصی گروه فعال است",
|
||||
"tgPrivacyOnDesc": "این ربات فقط در چت خصوصی پاسخ میدهد، بنابراین گروهها را عمداً نادیده میگیرد. حریم خصوصی گروه فقط تعیین میکند که ربات اجازه خواندن پیامهای گروه را دارد یا نه، که از آن استفاده نمیکند.",
|
||||
"subHostSortMode": "ترتیب گرههای اشتراک",
|
||||
"subHostSortInbound": "ترتیب ورودیها (پیشفرض)",
|
||||
"subHostSortHosts": "ترتیب میزبانها",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ صفحه کلید بسته شد!",
|
||||
"noResult": "❗ نتیجه ای یافت نشد!",
|
||||
"noQuery": "❌ درخواست یافت نشد! لطفا دوباره تلاش کنید!",
|
||||
"wentWrong": "❌ مشکلی پیش آمد!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ دستور ناشناخته",
|
||||
"pleaseChoose": "👇 لطفاً انتخاب کنید:\r\n",
|
||||
"help": "🤖 به این ربات خوش آمدید! این ربات برای ارائه دادههای خاص از سرور طراحی شده است و به شما امکان تغییرات لازم را میدهد.\r\n\r\n",
|
||||
"start": "👋 سلام <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 به ربات مدیریت <b>{{ .Hostname }}</b> خوش آمدید.\r\n",
|
||||
"status": "✅ ربات در حالت عادی است!",
|
||||
"usage": "❗ لطفاً یک متن برای جستجو وارد کنید!",
|
||||
"getID": "🆔 شناسه شما: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "برای راهاندازی مجدد Xray Core:\r\n<code>/restart</code>\r\n\r\nبرای جستجوی ایمیل مشتری:\r\n<code>/usage [ایمیل]</code>\r\n\r\nبرای جستجوی ورودیها (با آمار مشتری):\r\n<code>/inbound [توضیحات]</code>\r\n\r\nشناسه گفتگوی تلگرام:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "برای جستجوی آمار، از دستور زیر استفاده کنید:\r\n<code>/usage [ایمیل]</code>\r\n\r\nشناسه گفتگوی تلگرام:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ خطا در عملیات.\r\n\r\n<code>خطا: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core در حال اجرا نیست.",
|
||||
"startDesc": "نمایش منوی اصلی",
|
||||
"helpDesc": "راهنمای ربات",
|
||||
"statusDesc": "بررسی وضعیت ربات",
|
||||
"idDesc": "نمایش شناسه تلگرام شما",
|
||||
"usageDesc": "مشاهده مصرف کاربر: /usage ایمیل",
|
||||
"inboundDesc": "جستجوی ورودیها: /inbound نام (مدیر)",
|
||||
"restartDesc": "راهاندازی مجدد هسته Xray (مدیر)",
|
||||
"broadcastDesc": "ارسال پیام به همه کاربران (مدیر)",
|
||||
"clearallDesc": "صفر کردن ترافیک همه کاربران (مدیر)"
|
||||
"statusOk": "✅ ربات سالم است!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "بار پردازنده {{ .Percent }}% بیشتر از آستانه است {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ این لینک دعوت نامعتبر است یا قبلاً استفاده شده است.\r\n",
|
||||
"inviteRateLimited": "⏳ تعداد تلاشهای دعوت بیش از حد مجاز است. لطفاً بعداً دوباره امتحان کنید.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ تلاشهای دعوت برای {{ .User }} (<code>{{ .ID }}</code>) مسدود شد: بیش از {{ .Limit }} تلاش در یک ساعت. ممکن است کسی در حال حدس زدن شناسههای اشتراک باشد.",
|
||||
"inviteLink": "🔗 لینک دعوت برای <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nاولین کسی که آن را باز کند به این کاربر متصل میشود.\r\n",
|
||||
"exhaustedCount": "🚨 تعداد {{ .Type }} بهاتمامرسیدهاست:\r\n",
|
||||
"onlinesCount": "🌐 کاربرانآنلاین: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 غیرفعال: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 تازهسازی شده در: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ بله",
|
||||
"no": "❌ خیر",
|
||||
"received_email": "📧📥 ایمیل بهروزرسانی شد.",
|
||||
"received_comment": "💬📥 نظر بهروزرسانی شد.",
|
||||
"email_prompt": "📧 ایمیل پیشفرض: {{ .ClientEmail }}\n\nایمیل خود را وارد کنید.",
|
||||
"comment_prompt": "💬 نظر پیشفرض: {{ .ClientComment }}\n\nنظر خود را وارد کنید.",
|
||||
"tgid_prompt": "🆔 کاربر پیشفرض TG: {{ .ClientTGID }}\n\nشناسه عددی کاربر را برای اتصال به این کلاینت بفرستید، یا برای پاک کردن - / none را بفرستید.",
|
||||
"cancel": "❌ فرآیند لغو شد! \n\nمیتوانید هر زمان که خواستید /start را دوباره اجرا کنید. 🔄",
|
||||
"error_add_client": "⚠️ خطا:\n\n {{ .error }}",
|
||||
"using_default_value": "باشه، از مقدار پیشفرض استفاده میکنم. 😊",
|
||||
"incorrect_input": "ورودی شما معتبر نیست.\nعبارتها باید بدون فاصله باشند.\nمثال صحیح: aaaaaa\nمثال نادرست: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 ارسال گروهی: پیام خود را برای ارسال به کاربران بفرستید — متن، عکس، ویدیو، فایل یا یک آلبوم کامل.",
|
||||
"broadcastAlreadyRunning": "❗ یک ارسال گروهی در حال اجراست، تا پایان آن صبر کنید.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "نود {{ .Name }} قطع است",
|
||||
"eventNodeUp": "نود {{ .Name }} وصل است",
|
||||
"eventLoginFallback": "ورود ناموفق از {{ .Source }}",
|
||||
"memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است"
|
||||
"memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 سرور",
|
||||
"category_clients": "👥 کاربران",
|
||||
"category_traffic": "🚦 ترافیک",
|
||||
"category_maintenance": "⚙️ نگهداری",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 کدهای QR:",
|
||||
"qrLegendSubscription": "{{ .Code }}. اشتراک",
|
||||
"qrLegendJSON": "{{ .Code }}. اشتراک JSON",
|
||||
"qrLegendLink": "{{ .Code }}. لینک برنامه {{ .App }}",
|
||||
"backupFailed": "خواندن پایگاه داده ممکن نشد؛ هیچ نسخه پشتیبانی ارسال نشد.",
|
||||
"privateOnly": "🤖 این ربات فقط در چت خصوصی کار میکند. لطفاً آن را در پیام مستقیم باز کنید."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ بستن کیبورد",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ تأیید پاکسازی آدرسهای آیپی؟",
|
||||
"confirmRemoveTGUser": "✅ تأیید حذف کاربر تلگرام؟",
|
||||
"confirmToggle": "✅ تایید فعال/غیرفعال کردن کاربر؟",
|
||||
"dbBackup": "دریافت پشتیبان",
|
||||
"serverUsage": "استفاده از سیستم",
|
||||
"getInbounds": "دریافت ورودیها",
|
||||
"depleteSoon": "بهزودی به پایان خواهد رسید",
|
||||
"clientUsage": "دریافت آمار کاربر",
|
||||
"onlines": "کاربران آنلاین",
|
||||
"commands": "دستورات",
|
||||
"dbBackup": "💾 دریافت پشتیبان",
|
||||
"serverUsage": "📊 استفاده از سیستم",
|
||||
"getInbounds": "🔌 دریافت ورودیها",
|
||||
"depleteSoon": "⚠️ بهزودی به پایان خواهد رسید",
|
||||
"onlines": "🟢 کاربران آنلاین",
|
||||
"catServer": "🖥 سرور",
|
||||
"catClients": "👥 کاربران",
|
||||
"catTraffic": "🚦 ترافیک",
|
||||
"catMaintenance": "⚙️ نگهداری",
|
||||
"refresh": "🔄 تازهسازی",
|
||||
"clearIPs": "❌ پاکسازی آدرسها",
|
||||
"removeTGUser": "❌ حذف کاربر تلگرام",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ تایید: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ تایید اضافه کردن: {{ .Num }}",
|
||||
"limitTraffic": "🚧 محدودیت ترافیک",
|
||||
"getBanLogs": "گزارش های بلوک را دریافت کنید",
|
||||
"allClients": "همه مشتریان",
|
||||
"addClient": "افزودن مشتری",
|
||||
"getBanLogs": "📄 گزارش های بلوک را دریافت کنید",
|
||||
"allClients": "👥 همه مشتریان",
|
||||
"addClient": "➕ افزودن مشتری",
|
||||
"submitDisable": "ارسال به عنوان غیرفعال ☑️",
|
||||
"submitEnable": "ارسال به عنوان فعال ✅",
|
||||
"use_default": "🏷️ استفاده از پیشفرض",
|
||||
"change_email": "⚙️📧 ایمیل",
|
||||
"change_comment": "⚙️💬 نظر",
|
||||
"ResetAllTraffics": "بازنشانی همه ترافیکها",
|
||||
"SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتبشده"
|
||||
"ResetAllTraffics": "♻️ بازنشانی همه ترافیکها",
|
||||
"SortedTrafficUsageReport": "📈 گزارش استفاده از ترافیک مرتبشده",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 همگانی",
|
||||
"attachInbound": "افزودن ورودی ({{ .Count }})",
|
||||
"attachDone": "✅ انجام شد"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 ارسال گروهی آغاز شد.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید",
|
||||
"chooseInbound": "یک ورودی انتخاب کنید",
|
||||
"qrCodeForClient": "کد QR برای کاربر {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "منقضی",
|
||||
"fromFirstUse": "از اولین استفاده"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Diunggah",
|
||||
"expiry": "Kedaluwarsa",
|
||||
"totalQuota": "Kuota total",
|
||||
"individualLinks": "Tautan individual",
|
||||
"active": "Aktif",
|
||||
"inactive": "Nonaktif",
|
||||
"unlimited": "Tanpa batas",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "IP server DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP server DoH untuk bootstrap.",
|
||||
"subIncyGroupRouting": "Perutean",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "Privasi grup aktif",
|
||||
"tgPrivacyOnDesc": "Bot ini hanya menjawab di obrolan pribadi, jadi grup sengaja diabaikan. Privasi grup hanya menentukan apakah bot boleh membaca pesan grup, yang tidak digunakan.",
|
||||
"subHostSortMode": "Urutan node langganan",
|
||||
"subHostSortInbound": "Urutan inbound (bawaan)",
|
||||
"subHostSortHosts": "Urutan host",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Keyboard ditutup!",
|
||||
"noResult": "❗ Tidak ada hasil!",
|
||||
"noQuery": "❌ Kueri tidak ditemukan! Silakan gunakan perintah lagi!",
|
||||
"wentWrong": "❌ Terjadi kesalahan!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Perintah tidak dikenal.",
|
||||
"pleaseChoose": "👇 Harap pilih:\r\n",
|
||||
"help": "🤖 Selamat datang di bot ini! Ini dirancang untuk menyediakan data tertentu dari panel web dan memungkinkan Anda melakukan modifikasi sesuai kebutuhan.\r\n\r\n",
|
||||
"start": "👋 Halo <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Selamat datang di <b>{{.Hostname }}</b> bot managemen.\r\n",
|
||||
"status": "✅ Bot dalam keadaan baik!",
|
||||
"usage": "❗ Harap berikan teks untuk mencari!",
|
||||
"getID": "🆔 ID Anda: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Untuk memulai ulang Xray Core:\r\n<code>/restart</code>\r\n\r\nUntuk mencari email klien:\r\n<code>/usage [Email]</code>\r\n\r\nUntuk mencari inbound (dengan statistik klien):\r\n<code>/inbound [Catatan]</code>\r\n\r\nID Obrolan Telegram:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "Untuk mencari statistik, gunakan perintah berikut:\r\n<code>/usage [Email]</code>\r\n\r\nID Obrolan Telegram:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Kesalahan dalam operasi.\r\n\r\n<code>Error: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core tidak berjalan.",
|
||||
"startDesc": "Tampilkan menu utama",
|
||||
"helpDesc": "Bantuan bot",
|
||||
"statusDesc": "Periksa status bot",
|
||||
"idDesc": "Tampilkan ID Telegram Anda",
|
||||
"usageDesc": "Lihat pemakaian klien: /usage email",
|
||||
"inboundDesc": "Cari inbound: /inbound nama (admin)",
|
||||
"restartDesc": "Mulai ulang inti Xray (admin)",
|
||||
"broadcastDesc": "Kirim pesan ke semua klien (admin)",
|
||||
"clearallDesc": "Reset trafik semua klien (admin)"
|
||||
"statusOk": "✅ Bot berfungsi!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "Beban CPU {{ .Percent }}% melebihi batas {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Tautan undangan ini tidak valid atau sudah digunakan.\r\n",
|
||||
"inviteRateLimited": "⏳ Terlalu banyak percobaan undangan. Silakan coba lagi nanti.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Percobaan undangan diblokir untuk {{ .User }} (<code>{{ .ID }}</code>): lebih dari {{ .Limit }} percobaan dalam satu jam. Seseorang mungkin sedang menebak ID langganan.",
|
||||
"inviteLink": "🔗 Tautan undangan untuk <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nOrang pertama yang membukanya akan tertaut ke klien ini.\r\n",
|
||||
"exhaustedCount": "🚨 Jumlah Habis {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Klien Online: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Dinonaktifkan: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Diperbarui Pada: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Ya",
|
||||
"no": "❌ Tidak",
|
||||
"received_email": "📧📥 Email diperbarui.",
|
||||
"received_comment": "💬📥 Komentar diperbarui.",
|
||||
"email_prompt": "📧 Email Default: {{ .ClientEmail }}\n\nMasukkan email Anda.",
|
||||
"comment_prompt": "💬 Komentar Default: {{ .ClientComment }}\n\nMasukkan komentar Anda.",
|
||||
"tgid_prompt": "🆔 Pengguna TG default: {{ .ClientTGID }}\n\nKirim id numerik pengguna untuk melampirkan ke klien ini, atau kirim - / none untuk menghapus.",
|
||||
"cancel": "❌ Proses Dibatalkan! \n\nAnda dapat /start lagi kapan saja. 🔄",
|
||||
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
|
||||
"using_default_value": "Oke, saya akan tetap menggunakan nilai default. 😊",
|
||||
"incorrect_input": "Masukan Anda tidak valid.\nFrasa harus berlanjut tanpa spasi.\nContoh benar: aaaaaa\nContoh salah: aaa aaa 🚫",
|
||||
"broadcastAskText": "📡 Siaran: kirimkan pesan untuk dikirimkan ke klien Anda — teks, foto, video, file, atau satu album lengkap.",
|
||||
"broadcastAlreadyRunning": "❗ Siaran sedang berjalan, tunggu hingga selesai.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "Node {{ .Name }} MATI",
|
||||
"eventNodeUp": "Node {{ .Name }} AKTIF",
|
||||
"eventLoginFallback": "Gagal masuk dari {{ .Source }}",
|
||||
"memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%"
|
||||
"memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Server",
|
||||
"category_clients": "👥 Klien",
|
||||
"category_traffic": "🚦 Trafik",
|
||||
"category_maintenance": "⚙️ Pemeliharaan",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 Kode QR:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Langganan",
|
||||
"qrLegendJSON": "{{ .Code }}. Langganan JSON",
|
||||
"qrLegendLink": "{{ .Code }}. Tautan aplikasi {{ .App }}",
|
||||
"backupFailed": "Tidak dapat membaca basis data; tidak ada cadangan yang dikirim.",
|
||||
"privateOnly": "🤖 Bot ini hanya berfungsi di obrolan pribadi. Silakan buka di pesan langsung."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Tutup Papan Ketik",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ Konfirmasi Hapus IPs?",
|
||||
"confirmRemoveTGUser": "✅ Konfirmasi Hapus Pengguna Telegram?",
|
||||
"confirmToggle": "✅ Konfirmasi Aktifkan/Nonaktifkan Pengguna?",
|
||||
"dbBackup": "Dapatkan Cadangan DB",
|
||||
"serverUsage": "Penggunaan Server",
|
||||
"getInbounds": "Dapatkan Inbounds",
|
||||
"depleteSoon": "Habis Sebentar",
|
||||
"clientUsage": "Dapatkan Penggunaan",
|
||||
"onlines": "Klien Online",
|
||||
"commands": "Perintah",
|
||||
"dbBackup": "💾 Dapatkan Cadangan DB",
|
||||
"serverUsage": "📊 Penggunaan Server",
|
||||
"getInbounds": "🔌 Dapatkan Inbounds",
|
||||
"depleteSoon": "⚠️ Habis Sebentar",
|
||||
"onlines": "🟢 Klien Online",
|
||||
"catServer": "🖥 Server",
|
||||
"catClients": "👥 Klien",
|
||||
"catTraffic": "🚦 Trafik",
|
||||
"catMaintenance": "⚙️ Pemeliharaan",
|
||||
"refresh": "🔄 Perbarui",
|
||||
"clearIPs": "❌ Hapus IPs",
|
||||
"removeTGUser": "❌ Hapus Pengguna Telegram",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Konfirmasi: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Konfirmasi menambahkan: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Batas Lalu Lintas",
|
||||
"getBanLogs": "Dapatkan Log Pemblokiran",
|
||||
"allClients": "Semua Klien",
|
||||
"addClient": "Tambah Klien",
|
||||
"getBanLogs": "📄 Dapatkan Log Pemblokiran",
|
||||
"allClients": "👥 Semua Klien",
|
||||
"addClient": "➕ Tambah Klien",
|
||||
"submitDisable": "Kirim Sebagai Nonaktif ☑️",
|
||||
"submitEnable": "Kirim Sebagai Aktif ✅",
|
||||
"use_default": "🏷️ Gunakan Default",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Komentar",
|
||||
"ResetAllTraffics": "Reset Semua Lalu Lintas",
|
||||
"SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
|
||||
"ResetAllTraffics": "♻️ Reset Semua Lalu Lintas",
|
||||
"SortedTrafficUsageReport": "📈 Laporan Penggunaan Lalu Lintas yang Terurut",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Siaran",
|
||||
"attachInbound": "Lampirkan inbound ({{ .Count }})",
|
||||
"attachDone": "✅ Selesai"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Siaran dimulai.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Pilih Inbound",
|
||||
"qrCodeForClient": "Kode QR untuk klien {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "kedaluwarsa",
|
||||
"fromFirstUse": "sejak pemakaian pertama"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "アップロード",
|
||||
"expiry": "有効期限",
|
||||
"totalQuota": "合計クォータ",
|
||||
"individualLinks": "個別リンク",
|
||||
"active": "有効",
|
||||
"inactive": "無効",
|
||||
"unlimited": "無制限",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "DoH サーバー IP",
|
||||
"subIncyResolveDnsIpDesc": "ブートストラップ用の DoH サーバー IP。",
|
||||
"subIncyGroupRouting": "ルーティング",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "グループプライバシーが有効です",
|
||||
"tgPrivacyOnDesc": "このボットは個人チャットでのみ応答するため、グループは意図的に無視します。グループプライバシーはボットがグループメッセージを読めるかどうかを決めるだけで、実際には使用しません。",
|
||||
"subHostSortMode": "サブスクリプションのノード順序",
|
||||
"subHostSortInbound": "インバウンド順(既定)",
|
||||
"subHostSortHosts": "ホスト順",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ キーボードを閉じました!",
|
||||
"noResult": "❗ 結果がありません!",
|
||||
"noQuery": "❌ クエリが見つかりません!コマンドを再利用してください!",
|
||||
"wentWrong": "❌ 何かがうまくいかなかった!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ 不明なコマンド",
|
||||
"pleaseChoose": "👇 選択してください:\r\n",
|
||||
"help": "🤖 このボットをご利用いただきありがとうございます!サーバーから特定のデータを提供し、必要な変更を行うことができます。\r\n\r\n",
|
||||
"start": "👋 こんにちは、<i>{{ .Firstname }}</i>。\r\n",
|
||||
"welcome": "🤖 <b>{{ .Hostname }}</b> 管理ボットへようこそ。\r\n",
|
||||
"status": "✅ ボットは正常に動作しています!",
|
||||
"usage": "❗ 検索するテキストを入力してください!",
|
||||
"getID": "🆔 あなたのIDは:<code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Xray Coreを再起動するには:\r\n<code>/restart</code>\r\n\r\nクライアントの電子メールを検索するには:\r\n<code>/usage [電子メール]</code>\r\n\r\nインバウンド(クライアントの統計情報を含む)を検索するには:\r\n<code>/inbound [備考]</code>\r\n\r\nTelegramチャットID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "統計情報を検索するには、次のコマンドを使用してください:\r\n<code>/usage [電子メール]</code>\r\n\r\nTelegramチャットID:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ 操作エラー。\r\n\r\n<code>エラー: {{ .Error }}</code>",
|
||||
"xrayNotRunning": "❗ Xray Core は動作していません。",
|
||||
"startDesc": "メインメニューを表示",
|
||||
"helpDesc": "ボットのヘルプ",
|
||||
"statusDesc": "ボットの状態を確認",
|
||||
"idDesc": "Telegram IDを表示",
|
||||
"usageDesc": "クライアント使用量を表示: /usage メール",
|
||||
"inboundDesc": "インバウンド検索: /inbound 備考(管理者)",
|
||||
"restartDesc": "Xray コアを再起動(管理者)",
|
||||
"broadcastDesc": "全クライアントへ一括送信(管理者)",
|
||||
"clearallDesc": "全クライアントのトラフィックをリセット(管理者)"
|
||||
"statusOk": "✅ ボットは正常です!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "CPU使用率は{{ .Percent }}%、しきい値{{ .Threshold }}%を超えました",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ この招待リンクは無効か、すでに使用されています。\r\n",
|
||||
"inviteRateLimited": "⏳ 招待の試行回数が多すぎます。しばらくしてからもう一度お試しください。\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ {{ .User }} (<code>{{ .ID }}</code>) の招待の試行をブロックしました:1 時間に {{ .Limit }} 回を超えました。誰かがサブスクリプション ID を推測している可能性があります。",
|
||||
"inviteLink": "🔗 <code>{{ .Email }}</code> の招待リンク:\r\n<code>{{ .Link }}</code>\r\n\r\n最初に開いた人がこのクライアントにリンクされます。\r\n",
|
||||
"exhaustedCount": "🚨 消耗済みの {{ .Type }} 数量:\r\n",
|
||||
"onlinesCount": "🌐 オンラインクライアント:{{ .Count }}\r\n",
|
||||
"disabled": "🛑 無効化:{{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 更新時間:{{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ はい",
|
||||
"no": "❌ いいえ",
|
||||
"received_email": "📧📥 メールが更新されました。",
|
||||
"received_comment": "💬📥 コメントが更新されました。",
|
||||
"email_prompt": "📧 デフォルトメール: {{ .ClientEmail }}\n\nメールを入力してください。",
|
||||
"comment_prompt": "💬 デフォルトコメント: {{ .ClientComment }}\n\nコメントを入力してください。",
|
||||
"tgid_prompt": "🆔 既定のTGユーザー: {{ .ClientTGID }}\n\nこのクライアントに紐付けるユーザーの数値idを送信してください。解除するには - / none を送信します。",
|
||||
"cancel": "❌ プロセスがキャンセルされました!\n\nいつでも /start で再開できます。 🔄",
|
||||
"error_add_client": "⚠️ エラー:\n\n {{ .error }}",
|
||||
"using_default_value": "わかりました、デフォルト値を使用します。 😊",
|
||||
"incorrect_input": "入力が無効です。\nフレーズはスペースなしで続けて入力してください。\n正しい例: aaaaaa\n間違った例: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 一括送信:クライアントに配信するメッセージを送ってください — テキスト、写真、動画、ファイル、アルバム全体も OK。",
|
||||
"broadcastAlreadyRunning": "❗ 一括送信はすでに実行中です。完了までお待ちください。",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "ノード {{ .Name }} がダウンしています",
|
||||
"eventNodeUp": "ノード {{ .Name }} が復旧しました",
|
||||
"eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
|
||||
"memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました"
|
||||
"memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 サーバー",
|
||||
"category_clients": "👥 クライアント",
|
||||
"category_traffic": "🚦 トラフィック",
|
||||
"category_maintenance": "⚙️ メンテナンス",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 QRコード:",
|
||||
"qrLegendSubscription": "{{ .Code }}. サブスクリプション",
|
||||
"qrLegendJSON": "{{ .Code }}. JSON サブスクリプション",
|
||||
"qrLegendLink": "{{ .Code }}. アプリのリンク {{ .App }}",
|
||||
"backupFailed": "データベースを読み取れませんでした。バックアップは送信されていません。",
|
||||
"privateOnly": "🤖 このボットは個人チャットでのみ動作します。ダイレクトメッセージで開いてください。"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ キーボードを閉じる",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ IPをクリアしますか?",
|
||||
"confirmRemoveTGUser": "✅ Telegramユーザーを削除しますか?",
|
||||
"confirmToggle": "✅ ユーザーを有効/無効にしますか?",
|
||||
"dbBackup": "データベースバックアップを取得",
|
||||
"serverUsage": "サーバーの使用状況",
|
||||
"getInbounds": "インバウンド情報を取得",
|
||||
"depleteSoon": "間もなく消耗",
|
||||
"clientUsage": "使用状況を取得",
|
||||
"onlines": "オンラインクライアント",
|
||||
"commands": "コマンド",
|
||||
"dbBackup": "💾 データベースバックアップを取得",
|
||||
"serverUsage": "📊 サーバーの使用状況",
|
||||
"getInbounds": "🔌 インバウンド情報を取得",
|
||||
"depleteSoon": "⚠️ 間もなく消耗",
|
||||
"onlines": "🟢 オンラインクライアント",
|
||||
"catServer": "🖥 サーバー",
|
||||
"catClients": "👥 クライアント",
|
||||
"catTraffic": "🚦 トラフィック",
|
||||
"catMaintenance": "⚙️ メンテナンス",
|
||||
"refresh": "🔄 更新",
|
||||
"clearIPs": "❌ IPをクリア",
|
||||
"removeTGUser": "❌ Telegramユーザーを削除",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ 確認: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ 追加を確認:{{ .Num }}",
|
||||
"limitTraffic": "🚧 トラフィック制限",
|
||||
"getBanLogs": "禁止ログ",
|
||||
"allClients": "すべてのクライアント",
|
||||
"addClient": "クライアントを追加",
|
||||
"getBanLogs": "📄 禁止ログ",
|
||||
"allClients": "👥 すべてのクライアント",
|
||||
"addClient": "➕ クライアントを追加",
|
||||
"submitDisable": "無効として送信 ☑️",
|
||||
"submitEnable": "有効として送信 ✅",
|
||||
"use_default": "🏷️ デフォルトを使用",
|
||||
"change_email": "⚙️📧 メール",
|
||||
"change_comment": "⚙️💬 コメント",
|
||||
"ResetAllTraffics": "すべてのトラフィックをリセット",
|
||||
"SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
|
||||
"ResetAllTraffics": "♻️ すべてのトラフィックをリセット",
|
||||
"SortedTrafficUsageReport": "📈 ソートされたトラフィック使用レポート",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 一斉送信",
|
||||
"attachInbound": "インバウンドを追加 ({{ .Count }})",
|
||||
"attachDone": "✅ 完了"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 一括送信を開始しました。",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択",
|
||||
"chooseInbound": "インバウンドを選択",
|
||||
"qrCodeForClient": "クライアント {{ .Email }} のQRコード:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "期限切れ",
|
||||
"fromFirstUse": "初回使用から"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Enviado",
|
||||
"expiry": "Validade",
|
||||
"totalQuota": "Cota total",
|
||||
"individualLinks": "Links individuais",
|
||||
"active": "Ativo",
|
||||
"inactive": "Inativo",
|
||||
"unlimited": "Ilimitado",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "IP do servidor DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP do servidor DoH para bootstrap.",
|
||||
"subIncyGroupRouting": "Roteamento",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "A privacidade em grupos está ativada",
|
||||
"tgPrivacyOnDesc": "Este bot responde apenas no chat privado, então ignora os grupos de propósito. A privacidade em grupos só decide se o bot pode ler mensagens do grupo, o que ele não usa.",
|
||||
"subHostSortMode": "Ordem dos nós da assinatura",
|
||||
"subHostSortInbound": "Ordem das entradas (padrão)",
|
||||
"subHostSortHosts": "Ordem dos hosts",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Teclado fechado!",
|
||||
"noResult": "❗ Nenhum resultado!",
|
||||
"noQuery": "❌ Consulta não encontrada! Por favor, use o comando novamente!",
|
||||
"wentWrong": "❌ Algo deu errado!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Comando desconhecido.",
|
||||
"pleaseChoose": "👇 Escolha:\r\n",
|
||||
"help": "🤖 Bem-vindo a este bot! Ele foi projetado para oferecer dados específicos do painel da web e permite que você faça as modificações necessárias.\r\n\r\n",
|
||||
"start": "👋 Olá <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Bem-vindo ao bot de gerenciamento do <b>{{ .Hostname }}</b>.\r\n",
|
||||
"status": "✅ Bot está OK!",
|
||||
"usage": "❗ Por favor, forneça um texto para pesquisar!",
|
||||
"getID": "🆔 Seu ID: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Para reiniciar o Xray Core:\r\n<code>/restart</code>\r\n\r\nPara pesquisar por um email de cliente:\r\n<code>/usage [Email]</code>\r\n\r\nPara pesquisar por inbounds (com estatísticas do cliente):\r\n<code>/inbound [Remark]</code>\r\n\r\nTelegram Chat ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "Para pesquisar por estatísticas, use o seguinte comando:\r\n\r\n<code>/usage [Email]</code>\r\n\r\nTelegram Chat ID:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Erro na operação.\r\n\r\n<code>Erro: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core não está em execução.",
|
||||
"startDesc": "Mostrar menu principal",
|
||||
"helpDesc": "Ajuda do bot",
|
||||
"statusDesc": "Verificar status do bot",
|
||||
"idDesc": "Mostrar seu ID do Telegram",
|
||||
"usageDesc": "Ver o uso do cliente: /usage email",
|
||||
"inboundDesc": "Buscar entradas: /inbound nome (admin)",
|
||||
"restartDesc": "Reiniciar o núcleo Xray (admin)",
|
||||
"broadcastDesc": "Enviar uma mensagem para todos os clientes (admin)",
|
||||
"clearallDesc": "Zerar o tráfego de todos os clientes (admin)"
|
||||
"statusOk": "✅ O bot está OK!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "A carga da CPU {{ .Percent }}% excede o limite de {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Este link de convite é inválido ou já foi utilizado.\r\n",
|
||||
"inviteRateLimited": "⏳ Muitas tentativas de convite. Tente novamente mais tarde.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Tentativas de convite bloqueadas para {{ .User }} (<code>{{ .ID }}</code>): mais de {{ .Limit }} tentativas em uma hora. Alguém pode estar tentando adivinhar IDs de assinatura.",
|
||||
"inviteLink": "🔗 Link de convite para <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nA primeira pessoa que abri-lo será vinculada a este cliente.\r\n",
|
||||
"exhaustedCount": "🚨 Contagem de {{ .Type }} esgotado:\r\n",
|
||||
"onlinesCount": "🌐 Clientes online: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Desativado: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Atualizado em: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Sim",
|
||||
"no": "❌ Não",
|
||||
"received_email": "📧📥 E-mail atualizado.",
|
||||
"received_comment": "💬📥 Comentário atualizado.",
|
||||
"email_prompt": "📧 E-mail Padrão: {{ .ClientEmail }}\n\nDigite seu e-mail.",
|
||||
"comment_prompt": "💬 Comentário Padrão: {{ .ClientComment }}\n\nDigite seu comentário.",
|
||||
"tgid_prompt": "🆔 Usuário TG padrão: {{ .ClientTGID }}\n\nEnvie o id numérico do usuário para vincular a este cliente, ou envie - / none para limpar.",
|
||||
"cancel": "❌ Processo Cancelado! \n\nVocê pode iniciar novamente a qualquer momento com /start. 🔄",
|
||||
"error_add_client": "⚠️ Erro:\n\n {{ .error }}",
|
||||
"using_default_value": "Tudo bem, vou manter o valor padrão. 😊",
|
||||
"incorrect_input": "Sua entrada não é válida.\nAs frases devem ser contínuas, sem espaços.\nExemplo correto: aaaaaa\nExemplo incorreto: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Transmissão: envie-me a mensagem para entregar aos seus clientes — texto, foto, vídeo, arquivo ou um álbum inteiro.",
|
||||
"broadcastAlreadyRunning": "❗ Já existe uma transmissão em andamento, aguarde a conclusão.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "O nó {{ .Name }} está INATIVO",
|
||||
"eventNodeUp": "O nó {{ .Name }} está ATIVO",
|
||||
"eventLoginFallback": "Falha de login a partir de {{ .Source }}",
|
||||
"memoryThreshold": "Uso de memória {{ .Percent }}% excede o limite de {{ .Threshold }}%"
|
||||
"memoryThreshold": "Uso de memória {{ .Percent }}% excede o limite de {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Servidor",
|
||||
"category_clients": "👥 Clientes",
|
||||
"category_traffic": "🚦 Tráfego",
|
||||
"category_maintenance": "⚙️ Manutenção",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 Códigos QR:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Assinatura",
|
||||
"qrLegendJSON": "{{ .Code }}. Assinatura JSON",
|
||||
"qrLegendLink": "{{ .Code }}. Link do app {{ .App }}",
|
||||
"backupFailed": "Não foi possível ler o banco de dados; nenhum backup foi enviado.",
|
||||
"privateOnly": "🤖 Este bot funciona apenas no chat privado. Abra-o em uma mensagem direta."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Fechar teclado",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ Confirmar limpar IPs?",
|
||||
"confirmRemoveTGUser": "✅ Confirmar remover usuário do Telegram?",
|
||||
"confirmToggle": "✅ Confirmar ativar/desativar usuário?",
|
||||
"dbBackup": "Obter backup do DB",
|
||||
"serverUsage": "Uso do servidor",
|
||||
"getInbounds": "Obter Inbounds",
|
||||
"depleteSoon": "Esgotar em breve",
|
||||
"clientUsage": "Obter uso",
|
||||
"onlines": "Clientes online",
|
||||
"commands": "Comandos",
|
||||
"dbBackup": "💾 Obter backup do DB",
|
||||
"serverUsage": "📊 Uso do servidor",
|
||||
"getInbounds": "🔌 Obter Inbounds",
|
||||
"depleteSoon": "⚠️ Esgotar em breve",
|
||||
"onlines": "🟢 Clientes online",
|
||||
"catServer": "🖥 Servidor",
|
||||
"catClients": "👥 Clientes",
|
||||
"catTraffic": "🚦 Tráfego",
|
||||
"catMaintenance": "⚙️ Manutenção",
|
||||
"refresh": "🔄 Atualizar",
|
||||
"clearIPs": "❌ Limpar IPs",
|
||||
"removeTGUser": "❌ Remover usuário do Telegram",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Confirmar: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Confirmar adicionar: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Limite de tráfego",
|
||||
"getBanLogs": "Obter logs de banimento",
|
||||
"allClients": "Todos os clientes",
|
||||
"addClient": "Adicionar Cliente",
|
||||
"getBanLogs": "📄 Obter logs de banimento",
|
||||
"allClients": "👥 Todos os clientes",
|
||||
"addClient": "➕ Adicionar Cliente",
|
||||
"submitDisable": "Enviar como Desativado ☑️",
|
||||
"submitEnable": "Enviar como Ativado ✅",
|
||||
"use_default": "🏷️ Usar padrão",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Comentário",
|
||||
"ResetAllTraffics": "Redefinir Todo o Tráfego",
|
||||
"SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
|
||||
"ResetAllTraffics": "♻️ Redefinir Todo o Tráfego",
|
||||
"SortedTrafficUsageReport": "📈 Relatório de Uso de Tráfego Ordenado",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Transmissão",
|
||||
"attachInbound": "Anexar inbound ({{ .Count }})",
|
||||
"attachDone": "✅ Concluído"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Transmissão iniciada.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Escolha um Inbound",
|
||||
"qrCodeForClient": "QR Code para o cliente {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "expirado",
|
||||
"fromFirstUse": "desde o primeiro uso"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Отправлено",
|
||||
"expiry": "Срок действия",
|
||||
"totalQuota": "Общий лимит",
|
||||
"individualLinks": "Индивидуальные ссылки",
|
||||
"active": "Активна",
|
||||
"inactive": "Неактивна",
|
||||
"unlimited": "Неограниченно",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "IP DoH-сервера",
|
||||
"subIncyResolveDnsIpDesc": "IP DoH-сервера, нужен для bootstrap.",
|
||||
"subIncyGroupRouting": "Маршрутизация",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "Режим приватности включён",
|
||||
"tgPrivacyOnDesc": "Бот работает только в личных сообщениях, поэтому группы он игнорирует намеренно. Приватность в группах решает лишь то, может ли бот читать сообщения группы, — а он это не использует.",
|
||||
"subHostSortMode": "Порядок узлов подписки",
|
||||
"subHostSortInbound": "Порядок входящих подключений (по умолчанию)",
|
||||
"subHostSortHosts": "Порядок хостов",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Клавиатура закрыта.",
|
||||
"noResult": "❗ Нет результатов.",
|
||||
"noQuery": "❌ Запрос не найден. Пожалуйста, повторите команду.",
|
||||
"wentWrong": "❌ Что-то пошло не так...",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Неизвестная команда",
|
||||
"pleaseChoose": "👇 Пожалуйста, выберите:\r\n",
|
||||
"help": "🤖 Добро пожаловать! Этот бот предназначен для предоставления вам данных с сервера и позволяет вносить изменения на него.\r\n\r\n",
|
||||
"start": "👋 Привет, <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Добро пожаловать в бота управления <b>{{ .Hostname }}</b>!\r\n",
|
||||
"status": "✅ Бот функционирует нормально.",
|
||||
"usage": "❗ Пожалуйста, укажите email для поиска.",
|
||||
"getID": "🆔 Ваш User ID: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "🔃 Для перезапуска Xray Core:\r\n<code>/restart</code>\r\n\r\n🔎 Для поиска клиента по email:\r\n<code>/usage [Email]</code>\r\n\r\n📊 Для поиска входящих подключений (со статистикой клиентов):\r\n<code>/inbound [имя подключения]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "💲 Для просмотра информации о вашей подписке используйте команду:\r\n<code>/usage [Email]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Ошибка при перезапуске Xray-core.\r\n\r\n<code>Ошибка: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core не запущен.",
|
||||
"startDesc": "Показать главное меню",
|
||||
"helpDesc": "Справка по боту",
|
||||
"statusDesc": "Проверить статус бота",
|
||||
"idDesc": "Показать ваш Telegram ID",
|
||||
"usageDesc": "Показать трафик клиента: /usage email",
|
||||
"inboundDesc": "Поиск входящих: /inbound имя (админ)",
|
||||
"restartDesc": "Перезапустить ядро Xray (админ)",
|
||||
"broadcastDesc": "Массовая рассылка всем клиентам (админ)",
|
||||
"clearallDesc": "Сбросить трафик всех клиентов (админ)"
|
||||
"statusOk": "✅ Бот в порядке!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "Загрузка процессора составляет {{ .Percent }}%, что превышает пороговое значение {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Эта ссылка-приглашение недействительна или уже использована.\r\n",
|
||||
"inviteRateLimited": "⏳ Слишком много попыток приглашения. Попробуйте позже.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Попытки приглашения заблокированы для {{ .User }} (<code>{{ .ID }}</code>): более {{ .Limit }} попыток за час. Возможно, кто-то подбирает ID подписок.",
|
||||
"inviteLink": "🔗 Ссылка-приглашение для <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nПервый, кто её откроет, будет привязан к этому клиенту.\r\n",
|
||||
"exhaustedCount": "🚨 Количество исчерпанных {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Клиентов онлайн: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Отключено: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Обновлено: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Да",
|
||||
"no": "❌ Нет",
|
||||
"received_email": "📧📥 Email обновлен.",
|
||||
"received_comment": "💬📥 Комментарий обновлён.",
|
||||
"email_prompt": "📧 Стандартный email: {{ .ClientEmail }}\n\nВведите ваш email.",
|
||||
"comment_prompt": "💬 Стандартный комментарий: {{ .ClientComment }}\n\nВведите ваш комментарий.",
|
||||
"tgid_prompt": "🆔 Стандартный TG-юзер: {{ .ClientTGID }}\n\nОтправьте числовой id пользователя, чтобы привязать к клиенту, либо - / none чтобы очистить.",
|
||||
"cancel": "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄",
|
||||
"error_add_client": "⚠️ Ошибка:\n\n {{ .error }}",
|
||||
"using_default_value": "Используется значение по умолчанию👌",
|
||||
"incorrect_input": "Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Рассылка: пришлите сообщение для ваших клиентов — текст, фото, видео, файл или целый альбом.",
|
||||
"broadcastAlreadyRunning": "❗ Рассылка уже идёт, дождитесь её завершения.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН",
|
||||
"eventNodeUp": "Узел {{ .Name }} В СЕТИ",
|
||||
"eventLoginFallback": "Неудачный вход с {{ .Source }}",
|
||||
"memoryThreshold": "🔴 Использование памяти {{ .Percent }}% превышает пороговое значение {{ .Threshold }}%"
|
||||
"memoryThreshold": "🔴 Использование памяти {{ .Percent }}% превышает пороговое значение {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Сервер",
|
||||
"category_clients": "👥 Клиенты",
|
||||
"category_traffic": "🚦 Трафик",
|
||||
"category_maintenance": "⚙️ Обслуживание",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 QR-коды:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Подписка",
|
||||
"qrLegendJSON": "{{ .Code }}. JSON-подписка",
|
||||
"qrLegendLink": "{{ .Code }}. Ссылка для приложения {{ .App }}",
|
||||
"backupFailed": "Не удалось прочитать базу данных; бэкап не отправлен.",
|
||||
"privateOnly": "🤖 Бот работает только в личных сообщениях. Открой его в личке."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Закрыть клавиатуру",
|
||||
@@ -2626,9 +2631,11 @@
|
||||
"serverUsage": "💻 Состояние сервера",
|
||||
"getInbounds": "🔌 Входящие подключения",
|
||||
"depleteSoon": "⚠️ Скоро конец",
|
||||
"clientUsage": "Статистика клиента",
|
||||
"onlines": "🟢 Онлайн",
|
||||
"commands": "🖱️ Команды",
|
||||
"catServer": "🖥 Сервер",
|
||||
"catClients": "👥 Клиенты",
|
||||
"catTraffic": "🚦 Трафик",
|
||||
"catMaintenance": "⚙️ Обслуживание",
|
||||
"refresh": "🔄 Обновить",
|
||||
"clearIPs": "❌ Очистить IP",
|
||||
"removeTGUser": "❌ Удалить пользователя Telegram",
|
||||
@@ -2653,8 +2660,22 @@
|
||||
"use_default": "🏷️ Использовать по умолчанию",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Комментарий",
|
||||
"ResetAllTraffics": "Сбросить весь трафик",
|
||||
"SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
|
||||
"ResetAllTraffics": "♻️ Сбросить весь трафик",
|
||||
"SortedTrafficUsageReport": "📈 Отсортированный отчет об использовании трафика",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Рассылка",
|
||||
"attachInbound": "Привязать инбаунд ({{ .Count }})",
|
||||
"attachDone": "✅ Готово"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Рассылка запущена.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}",
|
||||
"chooseInbound": "Выберите входящее подключение",
|
||||
"qrCodeForClient": "QR-код для клиента {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "истёк",
|
||||
"fromFirstUse": "с первого использования"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Odoslané",
|
||||
"expiry": "Platnosť do",
|
||||
"totalQuota": "Celkový dátový limit",
|
||||
"individualLinks": "Samostatné odkazy",
|
||||
"active": "Aktívne",
|
||||
"inactive": "Neaktívne",
|
||||
"unlimited": "Bez obmedzenia",
|
||||
@@ -1378,7 +1377,7 @@
|
||||
"telegramAPIServer": "Server API Telegramu",
|
||||
"telegramAPIServerDesc": "Server API na komunikáciu s Telegramom. Prázdne pole použije predvolený server.",
|
||||
"telegramChatId": "ID konverzácie správcu",
|
||||
"telegramChatIdDesc": "ID konverzácií správcov v Telegrame oddelené čiarkou. Získate ich od {'@'}userinfobot alebo príkazom '/id' v botovi.",
|
||||
"telegramChatIdDesc": "ID chatu (chatov) správcu v Telegrame. (oddelené čiarkou)(získate ho tu {'@'}userinfobot) alebo (použite príkaz '/id' v bote). Na posielanie do témy fóra pripojte ':topicId', napr. -1001234567890:42",
|
||||
"telegramNotifyTime": "Čas upozornení",
|
||||
"telegramNotifyTimeDesc": "Ako často Telegram bot posiela pravidelné správy. Vyberte interval alebo možnosť Vlastné a zadajte výraz crontab.",
|
||||
"notifyTime": {
|
||||
@@ -1950,6 +1949,11 @@
|
||||
"subIncyResolveDnsIp": "IP servera DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP adresa servera DoH použitá na počiatočné pripojenie.",
|
||||
"subIncyGroupRouting": "Smerovanie",
|
||||
"tgInlineMissing": "Inline režim je vypnutý",
|
||||
"tgInlineMissingDesc": "Zoznamy v bote sa otvárajú cez inline vyhľadávanie. Zapnite ho v @BotFather príkazom /setinline, inak ťuknutie na tlačidlo vyhľadávania nič neurobí.",
|
||||
"tgInlineRecheck": "Skontrolovať znova",
|
||||
"tgPrivacyOn": "Group Privacy je zapnuté",
|
||||
"tgPrivacyOnDesc": "Tento bot odpovedá len v súkromnom chate, takže skupiny zámerne ignoruje. Group Privacy určuje len to, či bot smie čítať správy v skupinách, čo nepoužíva.",
|
||||
"subHostSortMode": "Poradie uzlov v predplatnom",
|
||||
"subHostSortInbound": "Poradie inboundov (predvolené)",
|
||||
"subHostSortHosts": "Poradie hostiteľov",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Vlastná klávesnica bola zatvorená!",
|
||||
"noResult": "❗ Žiadny výsledok!",
|
||||
"noQuery": "❌ Dotaz sa nenašiel. Použite príkaz znova.",
|
||||
"wentWrong": "❌ Niečo sa nepodarilo!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Neznámy príkaz.",
|
||||
"pleaseChoose": "👇 Vyberte možnosť:\r\n",
|
||||
"help": "🤖 Vitajte! Tento bot zobrazuje údaje z webového panelu a umožňuje ich podľa potreby upravovať.\r\n\r\n",
|
||||
"start": "👋 Dobrý deň, <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Vitajte v botovi na správu <b>{{ .Hostname }}</b>.\r\n",
|
||||
"status": "✅ Bot je v poriadku!",
|
||||
"usage": "❗ Zadajte text na vyhľadanie!",
|
||||
"getID": "🆔 Vaše ID: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Reštart jadra Xray:\r\n<code>/restart</code>\r\n\r\nVyhľadanie klienta podľa názvu:\r\n<code>/usage [Email]</code>\r\n\r\nVyhľadanie vstupov so štatistikami klientov:\r\n<code>/inbound [Remark]</code>\r\n\r\nID konverzácie Telegramu:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "Štatistiky klienta vyhľadáte príkazom:\r\n\r\n<code>/usage [Email]</code>\r\n\r\nID konverzácie Telegramu:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Pri vykonávaní nastala chyba.\r\n\r\n<code>Chyba: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Jadro Xray nebeží.",
|
||||
"startDesc": "Zobraziť hlavné menu",
|
||||
"helpDesc": "Pomoc s botom",
|
||||
"statusDesc": "Skontrolovať stav bota",
|
||||
"idDesc": "Zobraziť vaše Telegram ID",
|
||||
"usageDesc": "Zobraziť spotrebu klienta: /usage email",
|
||||
"inboundDesc": "Vyhľadať vstupy: /inbound remark (správca)",
|
||||
"restartDesc": "Reštartovať jadro Xray (správca)",
|
||||
"broadcastDesc": "Poslať správu všetkým klientom (správca)",
|
||||
"clearallDesc": "Vynulovať spotrebu všetkých klientov (správca)"
|
||||
"statusOk": "✅ Bot je v poriadku!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "Záťaž CPU {{ .Percent }}% prekročila prah {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Tento pozývací odkaz je neplatný alebo už bol použitý.\r\n",
|
||||
"inviteRateLimited": "⏳ Priveľa pokusov o priradenie. Skúste to neskôr.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Pokusy o priradenie zablokované pre {{ .User }} (<code>{{ .ID }}</code>): viac ako {{ .Limit }} pokusov za hodinu. Niekto môže hádať ID odberov.",
|
||||
"inviteLink": "🔗 Pozývací odkaz pre <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nPrvý používateľ, ktorý ho otvorí, sa priradí k tomuto klientovi.\r\n",
|
||||
"exhaustedCount": "🚨 Počet s vyčerpaným limitom {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Pripojení klienti: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Vypnutí: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Obnovené: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Áno",
|
||||
"no": "❌ Nie",
|
||||
"received_email": "📧📥 Názov klienta bol aktualizovaný.",
|
||||
"received_comment": "💬📥 Poznámka bola aktualizovaná.",
|
||||
"email_prompt": "📧 Predvolený názov klienta: {{ .ClientEmail }}\n\nZadajte názov klienta.",
|
||||
"comment_prompt": "💬 Predvolená poznámka: {{ .ClientComment }}\n\nZadajte poznámku.",
|
||||
"tgid_prompt": "🆔 Predvolený používateľ TG: {{ .ClientTGID }}\n\nPošlite číselné ID používateľa, ktoré sa má priradiť k tomuto klientovi, alebo pošlite - / none na zrušenie.",
|
||||
"cancel": "❌ Akcia bola zrušená! \n\nKedykoľvek môžete začať znova príkazom /start. 🔄",
|
||||
"error_add_client": "⚠️ Chyba:\n\n {{ .error }}",
|
||||
"using_default_value": "Dobre, ponechám predvolenú hodnotu. 😊",
|
||||
"incorrect_input": "Zadaná hodnota nie je platná.\nText musí byť súvislý, bez medzier.\nSprávne: aaaaaa\nNesprávne: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Hromadná správa: pošlite obsah pre klientov — text, fotografiu, video, súbor alebo celý album.",
|
||||
"broadcastAlreadyRunning": "❗ Hromadné odosielanie už prebieha. Počkajte na jeho dokončenie.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "Server {{ .Name }} je NEDOSTUPNÝ",
|
||||
"eventNodeUp": "Server {{ .Name }} je DOSTUPNÝ",
|
||||
"eventLoginFallback": "Neúspešné prihlásenie z {{ .Source }}",
|
||||
"memoryThreshold": "Využitie pamäte {{ .Percent }}% prekročilo prah {{ .Threshold }}%"
|
||||
"memoryThreshold": "Využitie pamäte {{ .Percent }}% prekročilo prah {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inboundy: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Klienti: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Server",
|
||||
"category_clients": "👥 Klienti",
|
||||
"category_traffic": "🚦 Prenos",
|
||||
"category_maintenance": "⚙️ Údržba",
|
||||
"inlineHint": "\r\n🔍 Zoznam sa otvára cez vyhľadávanie: ťuknite na Hľadať a začnite písať.",
|
||||
"subURL": "🔗 Predplatné: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Pošlite tento odkaz klientovi: k tomuto klientovi sa priradí prvý účet Telegramu, ktorý ho otvorí.",
|
||||
"qrLegendHeader": "🔳 QR kódy:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Predplatné",
|
||||
"qrLegendJSON": "{{ .Code }}. Predplatné JSON",
|
||||
"qrLegendLink": "{{ .Code }}. Odkaz pre aplikáciu {{ .App }}",
|
||||
"backupFailed": "Databázu sa nepodarilo prečítať; záloha nebola odoslaná.",
|
||||
"privateOnly": "🤖 Tento bot funguje len v súkromnom chate. Otvorte ho, prosím, v priamej správe."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Zavrieť klávesnicu",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ Potvrdiť vyčistenie IP adries?",
|
||||
"confirmRemoveTGUser": "✅ Potvrdiť odobratie používateľa Telegramu?",
|
||||
"confirmToggle": "✅ Potvrdiť zapnutie/vypnutie používateľa?",
|
||||
"dbBackup": "Získať zálohu databázy",
|
||||
"serverUsage": "Využitie servera",
|
||||
"getInbounds": "Zobraziť vstupy",
|
||||
"depleteSoon": "Blíži sa vyčerpanie",
|
||||
"clientUsage": "Zobraziť spotrebu",
|
||||
"onlines": "Pripojení klienti",
|
||||
"commands": "Príkazy",
|
||||
"dbBackup": "💾 Záloha databázy",
|
||||
"serverUsage": "📊 Využitie servera",
|
||||
"getInbounds": "🔌 Inboundy",
|
||||
"depleteSoon": "⚠️ Čoskoro vyčerpaní",
|
||||
"onlines": "🟢 Klienti online",
|
||||
"catServer": "🖥 Server",
|
||||
"catClients": "👥 Klienti",
|
||||
"catTraffic": "🚦 Prenos",
|
||||
"catMaintenance": "⚙️ Údržba",
|
||||
"refresh": "🔄 Obnoviť",
|
||||
"clearIPs": "❌ Vyčistiť IP adresy",
|
||||
"removeTGUser": "❌ Odobrať používateľa Telegramu",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Potvrdiť: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Potvrdiť pridanie: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Dátový limit",
|
||||
"getBanLogs": "Zobraziť záznamy blokovania",
|
||||
"allClients": "Všetci klienti",
|
||||
"addClient": "Pridať klienta",
|
||||
"getBanLogs": "📄 Záznamy o blokovaní",
|
||||
"allClients": "👥 Všetci klienti",
|
||||
"addClient": "➕ Pridať klienta",
|
||||
"submitDisable": "Uložiť ako vypnuté ☑️",
|
||||
"submitEnable": "Uložiť ako zapnuté ✅",
|
||||
"use_default": "🏷️ Použiť predvolené",
|
||||
"change_email": "⚙️📧 Názov klienta",
|
||||
"change_comment": "⚙️💬 Poznámka",
|
||||
"ResetAllTraffics": "Vynulovať všetku spotrebu",
|
||||
"SortedTrafficUsageReport": "Zoradený prehľad spotreby"
|
||||
"ResetAllTraffics": "♻️ Vynulovať všetok prenos",
|
||||
"SortedTrafficUsageReport": "📈 Prehľad prenosu podľa využitia",
|
||||
"restartXray": "♻️ Reštartovať Xray",
|
||||
"back": "⬅️ Späť",
|
||||
"hide": "🙈 Skryť",
|
||||
"page": "Strana {{ .Page }} z {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Hľadať inbound…",
|
||||
"searchClients": "🔍 Hľadať klienta…",
|
||||
"inlineDisabled": "⚠️ Inline režim je vypnutý",
|
||||
"clientCard": "⬅️ Karta klienta",
|
||||
"links": "🔗 Odkazy",
|
||||
"individualLinks": "🔗 Jednotlivé odkazy",
|
||||
"qrCode": "🔳 QR kód",
|
||||
"broadcast": "📢 Hromadná správa",
|
||||
"attachInbound": "Pripojiť inbound ({{ .Count }})",
|
||||
"attachDone": "✅ Hotovo"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Hromadné odosielanie sa začalo.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Vyberte klienta vstupu {{ .Inbound }}",
|
||||
"chooseInbound": "Vyberte vstup",
|
||||
"qrCodeForClient": "QR kód pre klienta {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Hlavná",
|
||||
"status": "Server",
|
||||
"inbounds": "Inboundy",
|
||||
"clients": "Klienti",
|
||||
"client": "Klient",
|
||||
"links": "Odkazy",
|
||||
"qr": "QR kód",
|
||||
"wizard": "Nový klient",
|
||||
"reports": "Prehľad",
|
||||
"backup": "Záloha",
|
||||
"onlines": "Online",
|
||||
"deplete": "Dochádza",
|
||||
"commands": "Príkazy",
|
||||
"broadcast": "Hromadná správa"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nič sa nenašlo",
|
||||
"noneDesc": "Na zapnutie zoznamu použite v @BotFather príkaz /setinline.",
|
||||
"offTitle": "⚠️ Inline režim je pre tohto bota vypnutý",
|
||||
"offBody": "Zoznamy sa prehliadajú v inline režime. Zapnite ho v @BotFather: /setinline a potom ťuknite na Skontrolovať znova.",
|
||||
"recheck": "Skontrolovať znova",
|
||||
"expired": "vypršalo",
|
||||
"fromFirstUse": "od prvého použitia"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 opakované {{ .Count }}× · naposledy {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Yüklenen",
|
||||
"expiry": "Son Kullanma",
|
||||
"totalQuota": "Toplam Kota",
|
||||
"individualLinks": "Bireysel Bağlantılar",
|
||||
"active": "Aktif",
|
||||
"inactive": "Pasif",
|
||||
"unlimited": "Sınırsız",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "DoH sunucu IP'si",
|
||||
"subIncyResolveDnsIpDesc": "Bootstrap için DoH sunucu IP'si.",
|
||||
"subIncyGroupRouting": "Yönlendirme",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "Grup gizliliği açık",
|
||||
"tgPrivacyOnDesc": "Bu bot yalnızca özel sohbette yanıt verir, bu yüzden grupları bilerek yok sayar. Grup gizliliği yalnızca botun grup mesajlarını okuyup okuyamayacağını belirler; bot bunu kullanmaz.",
|
||||
"subHostSortMode": "Abonelik düğümü sıralaması",
|
||||
"subHostSortInbound": "Gelen bağlantı sırası (varsayılan)",
|
||||
"subHostSortHosts": "Ana bilgisayar sırası",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Klavye kapatıldı!",
|
||||
"noResult": "❗ Sonuç yok!",
|
||||
"noQuery": "❌ Sorgu bulunamadı! Lütfen komutu tekrar kullanın!",
|
||||
"wentWrong": "❌ Bir şeyler yanlış gitti!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Bilinmeyen komut.",
|
||||
"pleaseChoose": "👇 Lütfen seçin:\r\n",
|
||||
"help": "🤖 Bu bota hoş geldiniz! Web panelinden belirli verileri sunmak ve gerektiğinde değişiklik yapmanıza olanak tanımak için tasarlanmıştır.\r\n\r\n",
|
||||
"start": "👋 Merhaba <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 <b>{{ .Hostname }}</b> 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: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Xray Core'u yeniden başlatmak için:\r\n<code>/restart</code>\r\n\r\nBir kullanıcının istatistiklerini aramak için:\r\n<code>/usage [E-posta]</code>\r\n\r\nGelen bağlantılarnı aramak için (kullanıcı istatistikleri ile):\r\n<code>/inbound [Açıklama]</code>\r\n\r\nTelegram Sohbet Kimliği (Chat ID):\r\n<code>/id</code>",
|
||||
"helpClientCommands": "İstatistiklerinizi görmek için şu komutu kullanın:\r\n\r\n<code>/usage [E-posta]</code>\r\n\r\nTelegram Sohbet Kimliği:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ İşlem hatası.\r\n\r\n<code>Hata: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core çalışmıyor.",
|
||||
"startDesc": "Ana menüyü göster",
|
||||
"helpDesc": "Bot yardımı",
|
||||
"statusDesc": "Bot durumunu kontrol et",
|
||||
"idDesc": "Telegram Kimliğinizi gösterir",
|
||||
"usageDesc": "İstemci kullanımını göster: /usage e-posta",
|
||||
"inboundDesc": "Gelenleri ara: /inbound ad (yönetici)",
|
||||
"restartDesc": "Xray çekirdeğini yeniden başlat (yönetici)",
|
||||
"broadcastDesc": "Tüm istemcilere mesaj gönder (yönetici)",
|
||||
"clearallDesc": "Tüm istemcilerin trafiğini sıfırla (yönetici)"
|
||||
"statusOk": "✅ Bot çalışıyor!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "CPU Yükü ({{ .Percent }}%), {{ .Threshold }}% eşiğini aşıyor",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Bu davet bağlantısı geçersiz veya daha önce kullanılmış.\r\n",
|
||||
"inviteRateLimited": "⏳ Çok fazla davet denemesi. Lütfen daha sonra tekrar deneyin.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ {{ .User }} (<code>{{ .ID }}</code>) için davet denemeleri engellendi: bir saatte {{ .Limit }} denemeden fazla. Birisi abonelik kimliklerini tahmin etmeye çalışıyor olabilir.",
|
||||
"inviteLink": "🔗 <code>{{ .Email }}</code> için davet bağlantısı:\r\n<code>{{ .Link }}</code>\r\n\r\nBağlantıyı ilk açan kişi bu istemciye bağlanır.\r\n",
|
||||
"exhaustedCount": "🚨 Limiti Dolan {{ .Type }} sayısı:\r\n",
|
||||
"onlinesCount": "🌐 Çevrimiçi Kullanıcılar: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Devre Dışı: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Yenilendi: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Evet",
|
||||
"no": "❌ Hayır",
|
||||
"received_email": "📧📥 E-posta güncellendi.",
|
||||
"received_comment": "💬📥 Yorum güncellendi.",
|
||||
"email_prompt": "📧 Varsayılan E-posta: {{ .ClientEmail }}\n\nE-postanızı girin.",
|
||||
"comment_prompt": "💬 Varsayılan Yorum: {{ .ClientComment }}\n\nYorumunuzu girin.",
|
||||
"tgid_prompt": "🆔 Varsayılan TG kullanıcısı: {{ .ClientTGID }}\n\nBu istemciye bağlamak için kullanıcının sayısal id'sini gönderin veya temizlemek için - / none gönderin.",
|
||||
"cancel": "❌ İşlem iptal edildi! \n\nİstediğiniz zaman /start ile yeniden başlayabilirsiniz. 🔄",
|
||||
"error_add_client": "⚠️ Hata:\n\n {{ .error }}",
|
||||
"using_default_value": "Tamam, varsayılan değeri kullanacağım. 😊",
|
||||
"incorrect_input": "Geçersiz değer girdiniz.\nİfadeler arasında boşluk olmamalıdır.\nDoğru örnek: aaaaaa\nYanlış örnek: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Toplu gönderim: istemcilerinize iletmek istediğiniz mesajı bana gönderin — metin, fotoğraf, video, dosya veya bir albüm.",
|
||||
"broadcastAlreadyRunning": "❗ Zaten devam eden bir toplu gönderim var, bitmesini bekleyin.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI",
|
||||
"eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ",
|
||||
"eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız",
|
||||
"memoryThreshold": "Bellek kullanımı {{ .Percent }}% eşiği {{ .Threshold }}% aşıyor"
|
||||
"memoryThreshold": "Bellek kullanımı {{ .Percent }}% eşiği {{ .Threshold }}% aşıyor",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Sunucu",
|
||||
"category_clients": "👥 İstemciler",
|
||||
"category_traffic": "🚦 Trafik",
|
||||
"category_maintenance": "⚙️ Bakım",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 QR kodları:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Abonelik",
|
||||
"qrLegendJSON": "{{ .Code }}. JSON abonelik",
|
||||
"qrLegendLink": "{{ .Code }}. Uygulama bağlantısı {{ .App }}",
|
||||
"backupFailed": "Veritabanı okunamadı; yedek gönderilmedi.",
|
||||
"privateOnly": "🤖 Bu bot yalnızca özel sohbette çalışır. Lütfen doğrudan mesajda açın."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Klavyeyi Kapat",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ IP'leri Temizlemeyi Onayla?",
|
||||
"confirmRemoveTGUser": "✅ Telegram Kullanıcısını Kaldırmayı Onayla?",
|
||||
"confirmToggle": "✅ Kullanıcıyı Etkinleştirme/Devre Dışı Bırakmayı Onayla?",
|
||||
"dbBackup": "Veritabanı Yedeği Al",
|
||||
"serverUsage": "Sunucu Kullanımı",
|
||||
"getInbounds": "Gelen Bağlantılarnı Al",
|
||||
"depleteSoon": "Kotası Dolmak Üzere",
|
||||
"clientUsage": "Kullanıcı İstatistikleri",
|
||||
"onlines": "Çevrimiçi Kullanıcılar",
|
||||
"commands": "Komutlar",
|
||||
"dbBackup": "💾 Veritabanı Yedeği Al",
|
||||
"serverUsage": "📊 Sunucu Kullanımı",
|
||||
"getInbounds": "🔌 Gelen Bağlantılarnı Al",
|
||||
"depleteSoon": "⚠️ Kotası Dolmak Üzere",
|
||||
"onlines": "🟢 Çevrimiçi Kullanıcılar",
|
||||
"catServer": "🖥 Sunucu",
|
||||
"catClients": "👥 İstemciler",
|
||||
"catTraffic": "🚦 Trafik",
|
||||
"catMaintenance": "⚙️ Bakım",
|
||||
"refresh": "🔄 Yenile",
|
||||
"clearIPs": "❌ IP'leri Temizle",
|
||||
"removeTGUser": "❌ Telegram Kullanıcısını Kaldır",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Onayla: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Ekleme onayı: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Trafik Sınırı",
|
||||
"getBanLogs": "Yasak Günlüklerini Al",
|
||||
"allClients": "Tüm Kullanıcılar",
|
||||
"addClient": "Kullanıcı Ekle",
|
||||
"getBanLogs": "📄 Yasak Günlüklerini Al",
|
||||
"allClients": "👥 Tüm Kullanıcılar",
|
||||
"addClient": "➕ Kullanıcı Ekle",
|
||||
"submitDisable": "Devre Dışı Olarak Gönder ☑️",
|
||||
"submitEnable": "Etkin Olarak Gönder ✅",
|
||||
"use_default": "🏷️ Varsayılanı Kullan",
|
||||
"change_email": "⚙️📧 E-posta",
|
||||
"change_comment": "⚙️💬 Yorum",
|
||||
"ResetAllTraffics": "Tüm Trafikleri Sıfırla",
|
||||
"SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
|
||||
"ResetAllTraffics": "♻️ Tüm Trafikleri Sıfırla",
|
||||
"SortedTrafficUsageReport": "📈 Sıralı Trafik Kullanım Raporu",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Yayın",
|
||||
"attachInbound": "Inbound ekle ({{ .Count }})",
|
||||
"attachDone": "✅ Tamam"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Toplu gönderim başladı.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin",
|
||||
"chooseInbound": "Bir Gelen Bağlantı Seçin",
|
||||
"qrCodeForClient": "{{ .Email }} istemcisi için QR Kodu:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "süresi doldu",
|
||||
"fromFirstUse": "ilk kullanımdan"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Відвантажено",
|
||||
"expiry": "Термін дії",
|
||||
"totalQuota": "Загальна квота",
|
||||
"individualLinks": "Окремі посилання",
|
||||
"active": "Активна",
|
||||
"inactive": "Неактивна",
|
||||
"unlimited": "Безліміт",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "IP DoH-сервера",
|
||||
"subIncyResolveDnsIpDesc": "IP DoH-сервера для bootstrap.",
|
||||
"subIncyGroupRouting": "Маршрутизація",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "Режим приватності увімкнено",
|
||||
"tgPrivacyOnDesc": "Бот працює лише в особистих повідомленнях, тож групи він ігнорує навмисно. Приватність у групах визначає лише те, чи може бот читати повідомлення групи, — а він цього не використовує.",
|
||||
"subHostSortMode": "Порядок вузлів підписки",
|
||||
"subHostSortInbound": "Порядок вхідних підключень (типово)",
|
||||
"subHostSortHosts": "Порядок хостів",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Клавіатуру закрито!",
|
||||
"noResult": "❗ Немає результату!",
|
||||
"noQuery": "❌ Запит не знайдено! Будь ласка, використовуйте команду ще раз!",
|
||||
"wentWrong": "❌ Щось пішло не так!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Невідома команда.",
|
||||
"pleaseChoose": "👇 Будь ласка, виберіть:\r\n",
|
||||
"help": "🤖 Ласкаво просимо до цього бота! Він розроблений, щоб надавати певні дані з веб-панелі та дозволяє вносити зміни за потреби.\r\n\r\n",
|
||||
"start": "👋 Привіт <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Ласкаво просимо до <b>{{ .Hostname }}</b> бота керування.\r\n",
|
||||
"status": "✅ Бот в порядку!",
|
||||
"usage": "❗ Введіть текст для пошуку!",
|
||||
"getID": "🆔 Ваш ідентифікатор: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Для перезапуску Xray Core:\r\n<code>/restart</code>\r\n\r\nДля пошуку електронної пошти клієнта:\r\n<code>/usage [Електронна пошта]</code>\r\n\r\nДля пошуку вхідних (зі статистикою клієнта):\r\n<code>/inbound [Примітка]</code>\r\n\r\nID чату Telegram:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "Для пошуку статистики використовуйте наступну команду:\r\n<code>/usage [Електронна пошта]</code>\r\n\r\nID чату Telegram:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Помилка в операції.\r\n\r\n<code>Помилка: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core не запущений.",
|
||||
"startDesc": "Показати головне меню",
|
||||
"helpDesc": "Довідка по боту",
|
||||
"statusDesc": "Перевірити статус бота",
|
||||
"idDesc": "Показати ваш Telegram ID",
|
||||
"usageDesc": "Показати трафік клієнта: /usage email",
|
||||
"inboundDesc": "Пошук вхідних: /inbound назва (адмін)",
|
||||
"restartDesc": "Перезапустити ядро Xray (адмін)",
|
||||
"broadcastDesc": "Масове розсилання всім клієнтам (адмін)",
|
||||
"clearallDesc": "Скинути трафік усіх клієнтів (адмін)"
|
||||
"statusOk": "✅ Бот працює!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "Навантаження ЦП {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Це посилання-запрошення недійсне або вже використане.\r\n",
|
||||
"inviteRateLimited": "⏳ Забагато спроб запрошення. Спробуйте пізніше.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Спроби запрошення заблоковано для {{ .User }} (<code>{{ .ID }}</code>): понад {{ .Limit }} спроб за годину. Можливо, хтось підбирає ID підписок.",
|
||||
"inviteLink": "🔗 Посилання-запрошення для <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nПерший, хто його відкриє, буде прив'язаний до цього клієнта.\r\n",
|
||||
"exhaustedCount": "🚨 Вичерпано кількість {{ .Type }} count:\r\n",
|
||||
"onlinesCount": "🌐 Онлайн-клієнти: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Вимкнено: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Оновлено: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Так",
|
||||
"no": "❌ Ні",
|
||||
"received_email": "📧📥 Електронна пошта оновлена.",
|
||||
"received_comment": "💬📥 Коментар оновлено.",
|
||||
"email_prompt": "📧 Стандартний email: {{ .ClientEmail }}\n\nВведіть ваш email.",
|
||||
"comment_prompt": "💬 Стандартний коментар: {{ .ClientComment }}\n\nВведіть ваш коментар.",
|
||||
"tgid_prompt": "🆔 Стандартний TG-користувач: {{ .ClientTGID }}\n\nНадішліть числовий id користувача, щоб прив'язати до клієнта, або - / none щоб очистити.",
|
||||
"cancel": "❌ Процес скасовано! \n\nВи можете знову розпочати, використовуючи /start у будь-який час. 🔄",
|
||||
"error_add_client": "⚠️ Помилка:\n\n {{ .error }}",
|
||||
"using_default_value": "Гаразд, залишу значення за замовчуванням. 😊",
|
||||
"incorrect_input": "Ваш ввід невірний.\nФрази повинні бути без пробілів.\nПравильний приклад: aaaaaa\nНеправильний приклад: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Розсилка: надішліть повідомлення для ваших клієнтів — текст, фото, відео, файл або цілий альбом.",
|
||||
"broadcastAlreadyRunning": "❗ Розсилка вже триває, зачекайте її завершення.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ",
|
||||
"eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ",
|
||||
"eventLoginFallback": "Невдала спроба входу з {{ .Source }}",
|
||||
"memoryThreshold": "Використання пам'яті {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%"
|
||||
"memoryThreshold": "Використання пам'яті {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Сервер",
|
||||
"category_clients": "👥 Клієнти",
|
||||
"category_traffic": "🚦 Трафік",
|
||||
"category_maintenance": "⚙️ Обслуговування",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 QR-коди:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Підписка",
|
||||
"qrLegendJSON": "{{ .Code }}. JSON-підписка",
|
||||
"qrLegendLink": "{{ .Code }}. Посилання для застосунку {{ .App }}",
|
||||
"backupFailed": "Не вдалося прочитати базу даних; резервну копію не надіслано.",
|
||||
"privateOnly": "🤖 Бот працює лише в особистих повідомленнях. Відкрий його в особистому чаті."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Закрити клавіатуру",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ Підтвердити очищення IP-адрес?",
|
||||
"confirmRemoveTGUser": "✅ Підтвердити видалення користувача Telegram?",
|
||||
"confirmToggle": "✅ Підтвердити ввімкнути/вимкнути користувача?",
|
||||
"dbBackup": "Отримати резервну копію БД",
|
||||
"serverUsage": "Використання сервера",
|
||||
"getInbounds": "Отримати вхідні",
|
||||
"depleteSoon": "Скоро вичерпати",
|
||||
"clientUsage": "Отримати використання",
|
||||
"onlines": "Онлайн-клієнти",
|
||||
"commands": "Команди",
|
||||
"dbBackup": "💾 Отримати резервну копію БД",
|
||||
"serverUsage": "📊 Використання сервера",
|
||||
"getInbounds": "🔌 Отримати вхідні",
|
||||
"depleteSoon": "⚠️ Скоро вичерпати",
|
||||
"onlines": "🟢 Онлайн-клієнти",
|
||||
"catServer": "🖥 Сервер",
|
||||
"catClients": "👥 Клієнти",
|
||||
"catTraffic": "🚦 Трафік",
|
||||
"catMaintenance": "⚙️ Обслуговування",
|
||||
"refresh": "🔄 Оновити",
|
||||
"clearIPs": "❌ Очистити IP-адреси",
|
||||
"removeTGUser": "❌ Видалити користувача Telegram",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Підтвердити: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Підтвердити додавання: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Ліміт трафіку",
|
||||
"getBanLogs": "Отримати журнали заборон",
|
||||
"allClients": "Всі Клієнти",
|
||||
"addClient": "Додати клієнта",
|
||||
"getBanLogs": "📄 Отримати журнали заборон",
|
||||
"allClients": "👥 Всі Клієнти",
|
||||
"addClient": "➕ Додати клієнта",
|
||||
"submitDisable": "Надіслати як вимкнено ☑️",
|
||||
"submitEnable": "Надіслати як увімкнено ✅",
|
||||
"use_default": "🏷️ Використати типове",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Коментар",
|
||||
"ResetAllTraffics": "Скинути весь трафік",
|
||||
"SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
|
||||
"ResetAllTraffics": "♻️ Скинути весь трафік",
|
||||
"SortedTrafficUsageReport": "📈 Відсортований звіт про використання трафіку",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Розсилка",
|
||||
"attachInbound": "Прив’язати інбаунд ({{ .Count }})",
|
||||
"attachDone": "✅ Готово"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Розсилку запущено.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}",
|
||||
"chooseInbound": "Виберіть Вхідний",
|
||||
"qrCodeForClient": "QR-код для клієнта {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "минув",
|
||||
"fromFirstUse": "з першого використання"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "Đã tải lên",
|
||||
"expiry": "Hết hạn",
|
||||
"totalQuota": "Tổng hạn mức",
|
||||
"individualLinks": "Liên kết riêng lẻ",
|
||||
"active": "Hoạt động",
|
||||
"inactive": "Không hoạt động",
|
||||
"unlimited": "Không giới hạn",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "IP máy chủ DoH",
|
||||
"subIncyResolveDnsIpDesc": "IP máy chủ DoH để khởi tạo.",
|
||||
"subIncyGroupRouting": "Định tuyến",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "Quyền riêng tư nhóm đang bật",
|
||||
"tgPrivacyOnDesc": "Bot này chỉ trả lời trong trò chuyện riêng, nên nhóm bị bỏ qua có chủ đích. Quyền riêng tư nhóm chỉ quyết định bot có được đọc tin nhắn nhóm hay không, và bot không dùng đến.",
|
||||
"subHostSortMode": "Thứ tự nút đăng ký",
|
||||
"subHostSortInbound": "Thứ tự kết nối vào (mặc định)",
|
||||
"subHostSortHosts": "Thứ tự máy chủ",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ Bàn phím đã đóng!",
|
||||
"noResult": "❗ Không có kết quả!",
|
||||
"noQuery": "❌ Không tìm thấy truy vấn! Vui lòng sử dụng lại lệnh!",
|
||||
"wentWrong": "❌ Đã xảy ra lỗi!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ Lệnh không rõ",
|
||||
"pleaseChoose": "👇 Vui lòng chọn:\r\n",
|
||||
"help": "🤖 Chào mừng bạn đến với bot này! Bot được thiết kế để cung cấp cho bạn dữ liệu cụ thể từ máy chủ và cho phép bạn thực hiện các thay đổi cần thiết.\r\n\r\n",
|
||||
"start": "👋 Xin chào <i>{{ .Firstname }}</i>.\r\n",
|
||||
"welcome": "🤖 Chào mừng đến với bot quản lý của <b>{{ .Hostname }}</b>.\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: <code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "Để khởi động lại Xray Core:\r\n<code>/restart</code>\r\n\r\nĐể tìm kiếm email của khách hàng:\r\n<code>/usage [Email]</code>\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<code>/inbound [Ghi chú]</code>\r\n\r\nID Trò chuyện Telegram:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "Để tìm kiếm thống kê, sử dụng lệnh sau:\r\n<code>/usage [Email]</code>\r\n\r\nID Trò chuyện Telegram:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ Lỗi trong quá trình hoạt động.\r\n\r\n<code>Lỗi: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core không chạy.",
|
||||
"startDesc": "Hiển thị menu chính",
|
||||
"helpDesc": "Trợ giúp bot",
|
||||
"statusDesc": "Kiểm tra trạng thái bot",
|
||||
"idDesc": "Hiển thị ID Telegram của bạn",
|
||||
"usageDesc": "Xem mức dùng của khách: /usage email",
|
||||
"inboundDesc": "Tìm inbound: /inbound tên (quản trị)",
|
||||
"restartDesc": "Khởi động lại lõi Xray (quản trị)",
|
||||
"broadcastDesc": "Gửi tin nhắn tới tất cả khách hàng (quản trị)",
|
||||
"clearallDesc": "Đặt lại lưu lượng mọi khách hàng (quản trị)"
|
||||
"statusOk": "✅ Bot hoạt động bình thường!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "Sử dụng CPU {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ Liên kết mời này không hợp lệ hoặc đã được sử dụng.\r\n",
|
||||
"inviteRateLimited": "⏳ Quá nhiều lần thử lời mời. Vui lòng thử lại sau.\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ Đã chặn các lần thử lời mời của {{ .User }} (<code>{{ .ID }}</code>): hơn {{ .Limit }} lần trong một giờ. Có thể ai đó đang đoán ID đăng ký.",
|
||||
"inviteLink": "🔗 Liên kết mời cho <code>{{ .Email }}</code>:\r\n<code>{{ .Link }}</code>\r\n\r\nNgười đầu tiên mở nó sẽ được liên kết với client này.\r\n",
|
||||
"exhaustedCount": "🚨 Số lần cạn kiệt {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Khách hàng trực tuyến: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Vô hiệu hóa: {{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 Đã cập nhật lần cuối vào: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Có",
|
||||
"no": "❌ Không",
|
||||
"received_email": "📧📥 Email đã được cập nhật.",
|
||||
"received_comment": "💬📥 Bình luận đã được cập nhật.",
|
||||
"email_prompt": "📧 Email mặc định: {{ .ClientEmail }}\n\nVui lòng nhập email của bạn.",
|
||||
"comment_prompt": "💬 Bình luận mặc định: {{ .ClientComment }}\n\nVui lòng nhập bình luận của bạn.",
|
||||
"tgid_prompt": "🆔 Người dùng TG mặc định: {{ .ClientTGID }}\n\nGửi id số của người dùng để gắn vào client này, hoặc gửi - / none để xóa.",
|
||||
"cancel": "❌ Quá trình đã bị hủy! \n\nBạn có thể bắt đầu lại bất cứ lúc nào bằng cách nhập /start. 🔄",
|
||||
"error_add_client": "⚠️ Lỗi:\n\n {{ .error }}",
|
||||
"using_default_value": "Được rồi, tôi sẽ sử dụng giá trị mặc định. 😊",
|
||||
"incorrect_input": "Dữ liệu bạn nhập không hợp lệ.\nCác chuỗi phải liền mạch và không có dấu cách.\nVí dụ đúng: aaaaaa\nVí dụ sai: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 Phát tin: gửi cho tôi tin nhắn để chuyển tới khách hàng của bạn — văn bản, ảnh, video, tệp hoặc cả bộ ảnh.",
|
||||
"broadcastAlreadyRunning": "❗ Một bản phát đang chạy, hãy đợi nó hoàn tất.",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG",
|
||||
"eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG",
|
||||
"eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}",
|
||||
"memoryThreshold": "Sử dụng bộ nhớ {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%"
|
||||
"memoryThreshold": "Sử dụng bộ nhớ {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 Máy chủ",
|
||||
"category_clients": "👥 Client",
|
||||
"category_traffic": "🚦 Lưu lượng",
|
||||
"category_maintenance": "⚙️ Bảo trì",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 Mã QR:",
|
||||
"qrLegendSubscription": "{{ .Code }}. Gói đăng ký",
|
||||
"qrLegendJSON": "{{ .Code }}. Gói đăng ký JSON",
|
||||
"qrLegendLink": "{{ .Code }}. Liên kết ứng dụng {{ .App }}",
|
||||
"backupFailed": "Không đọc được cơ sở dữ liệu; không có bản sao lưu nào được gửi.",
|
||||
"privateOnly": "🤖 Bot này chỉ hoạt động trong trò chuyện riêng. Hãy mở nó trong tin nhắn trực tiếp."
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Đóng Bàn Phím",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ Xác Nhận Xóa Các IP?",
|
||||
"confirmRemoveTGUser": "✅ Xác Nhận Xóa Người Dùng Telegram?",
|
||||
"confirmToggle": "✅ Xác nhận Bật/Tắt người dùng?",
|
||||
"dbBackup": "Tải bản sao lưu cơ sở dữ liệu",
|
||||
"serverUsage": "Sử Dụng Máy Chủ",
|
||||
"getInbounds": "Lấy cổng vào",
|
||||
"depleteSoon": "Depleted Soon",
|
||||
"clientUsage": "Lấy Sử Dụng",
|
||||
"onlines": "Khách hàng trực tuyến",
|
||||
"commands": "Lệnh",
|
||||
"dbBackup": "💾 Tải bản sao lưu cơ sở dữ liệu",
|
||||
"serverUsage": "📊 Sử Dụng Máy Chủ",
|
||||
"getInbounds": "🔌 Lấy cổng vào",
|
||||
"depleteSoon": "⚠️ Depleted Soon",
|
||||
"onlines": "🟢 Khách hàng trực tuyến",
|
||||
"catServer": "🖥 Máy chủ",
|
||||
"catClients": "👥 Client",
|
||||
"catTraffic": "🚦 Lưu lượng",
|
||||
"catMaintenance": "⚙️ Bảo trì",
|
||||
"refresh": "🔄 Cập Nhật",
|
||||
"clearIPs": "❌ Xóa IP",
|
||||
"removeTGUser": "❌ Xóa Người Dùng Telegram",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ Xác nhận: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ Xác nhận thêm: {{ .Num }}",
|
||||
"limitTraffic": "🚧 Giới hạn lưu lượng",
|
||||
"getBanLogs": "Cấm nhật ký",
|
||||
"allClients": "Tất cả Khách hàng",
|
||||
"addClient": "Thêm Khách Hàng",
|
||||
"getBanLogs": "📄 Cấm nhật ký",
|
||||
"allClients": "👥 Tất cả Khách hàng",
|
||||
"addClient": "➕ Thêm Khách Hàng",
|
||||
"submitDisable": "Gửi Dưới Dạng Vô Hiệu ☑️",
|
||||
"submitEnable": "Gửi Dưới Dạng Kích Hoạt ✅",
|
||||
"use_default": "🏷️ Sử Dụng Mặc Định",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Bình Luận",
|
||||
"ResetAllTraffics": "Đặt lại tất cả lưu lượng",
|
||||
"SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
|
||||
"ResetAllTraffics": "♻️ Đặt lại tất cả lưu lượng",
|
||||
"SortedTrafficUsageReport": "📈 Báo cáo sử dụng lưu lượng đã sắp xếp",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 Phát tin",
|
||||
"attachInbound": "Đính kèm inbound ({{ .Count }})",
|
||||
"attachDone": "✅ Xong"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 Đã bắt đầu phát tin.",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Chọn một Inbound",
|
||||
"qrCodeForClient": "Mã QR cho khách hàng {{ .Email }}:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "hết hạn",
|
||||
"fromFirstUse": "từ lần dùng đầu"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "已上传",
|
||||
"expiry": "到期",
|
||||
"totalQuota": "总配额",
|
||||
"individualLinks": "单独链接",
|
||||
"active": "启用",
|
||||
"inactive": "停用",
|
||||
"unlimited": "无限制",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "DoH 服务器 IP",
|
||||
"subIncyResolveDnsIpDesc": "用于引导的 DoH 服务器 IP。",
|
||||
"subIncyGroupRouting": "路由",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "群组隐私已开启",
|
||||
"tgPrivacyOnDesc": "此机器人只在私聊中回复,因此会刻意忽略群组。群组隐私仅决定机器人能否读取群消息,而它并不使用该权限。",
|
||||
"subHostSortMode": "订阅节点排序",
|
||||
"subHostSortInbound": "入站顺序(默认)",
|
||||
"subHostSortHosts": "主机顺序",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ 自定义键盘已关闭!",
|
||||
"noResult": "❗ 没有结果!",
|
||||
"noQuery": "❌ 未找到查询!请再次使用该命令!",
|
||||
"wentWrong": "❌ 出了点问题!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ 未知命令",
|
||||
"pleaseChoose": "👇 请选择:\r\n",
|
||||
"help": "🤖 欢迎使用本机器人!它旨在为您提供来自服务器的特定数据,并允许您进行必要的修改。\r\n\r\n",
|
||||
"start": "👋 你好,<i>{{ .Firstname }}</i>。\r\n",
|
||||
"welcome": "🤖 欢迎来到 <b>{{ .Hostname }}</b> 管理机器人。\r\n",
|
||||
"status": "✅ 机器人正常运行!",
|
||||
"usage": "❗ 请输入要搜索的文本!",
|
||||
"getID": "🆔 您的 ID 为:<code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "要重新启动 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜索客户电子邮件:\r\n<code>/usage [电子邮件]</code>\r\n\r\n要搜索入站(带有客户统计数据):\r\n<code>/inbound [备注]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n<code>/usage [电子邮件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ 操作错误。\r\n\r\n<code>错误: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core 未运行。",
|
||||
"startDesc": "显示主菜单",
|
||||
"helpDesc": "机器人帮助",
|
||||
"statusDesc": "检查机器人状态",
|
||||
"idDesc": "显示您的 Telegram ID",
|
||||
"usageDesc": "查看客户端用量:/usage 邮箱",
|
||||
"inboundDesc": "搜索入站:/inbound 备注(管理员)",
|
||||
"restartDesc": "重启 Xray 内核(管理员)",
|
||||
"broadcastDesc": "向所有客户端群发消息(管理员)",
|
||||
"clearallDesc": "重置所有客户端流量(管理员)"
|
||||
"statusOk": "✅ 机器人运行正常!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "CPU 使用率为 {{ .Percent }}%,超过阈值 {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ 此邀请链接无效或已被使用。\r\n",
|
||||
"inviteRateLimited": "⏳ 邀请尝试次数过多,请稍后再试。\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ 已阻止 {{ .User }}(<code>{{ .ID }}</code>)的邀请尝试:一小时内超过 {{ .Limit }} 次。可能有人在猜测订阅 ID。",
|
||||
"inviteLink": "🔗 <code>{{ .Email }}</code> 的邀请链接:\r\n<code>{{ .Link }}</code>\r\n\r\n第一个打开它的人将绑定到此客户端。\r\n",
|
||||
"exhaustedCount": "🚨 耗尽的 {{ .Type }} 数量:\r\n",
|
||||
"onlinesCount": "🌐 在线客户:{{ .Count }}\r\n",
|
||||
"disabled": "🛑 禁用:{{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 刷新时间:{{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ 是的",
|
||||
"no": "❌ 否",
|
||||
"received_email": "📧📥 邮箱已更新。",
|
||||
"received_comment": "💬📥 评论已更新。",
|
||||
"email_prompt": "📧 默认邮箱: {{ .ClientEmail }}\n\n请输入您的邮箱。",
|
||||
"comment_prompt": "💬 默认评论: {{ .ClientComment }}\n\n请输入您的评论。",
|
||||
"tgid_prompt": "🆔 默认 TG 用户:{{ .ClientTGID }}\n\n发送用户的数字 id 以绑定到此客户端,或发送 - / none 以清除。",
|
||||
"cancel": "❌ 进程已取消!\n\n您可以随时使用 /start 重新开始。 🔄",
|
||||
"error_add_client": "⚠️ 错误:\n\n {{ .error }}",
|
||||
"using_default_value": "好的,我会使用默认值。 😊",
|
||||
"incorrect_input": "您的输入无效。\n短语应连续输入,不能有空格。\n正确示例: aaaaaa\n错误示例: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 群发:请把要发给客户的消息发给我 — 文本、照片、视频、文件或整个相册都可以。",
|
||||
"broadcastAlreadyRunning": "❗ 已有群发正在进行,请等待其完成。",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "节点 {{ .Name }} 已离线",
|
||||
"eventNodeUp": "节点 {{ .Name }} 已上线",
|
||||
"eventLoginFallback": "来自 {{ .Source }} 的登录失败",
|
||||
"memoryThreshold": "内存使用率 {{ .Percent }}% 超过阈值 {{ .Threshold }}%"
|
||||
"memoryThreshold": "内存使用率 {{ .Percent }}% 超过阈值 {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 服务器",
|
||||
"category_clients": "👥 客户端",
|
||||
"category_traffic": "🚦 流量",
|
||||
"category_maintenance": "⚙️ 维护",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 二维码:",
|
||||
"qrLegendSubscription": "{{ .Code }}. 订阅",
|
||||
"qrLegendJSON": "{{ .Code }}. JSON 订阅",
|
||||
"qrLegendLink": "{{ .Code }}. 应用链接 {{ .App }}",
|
||||
"backupFailed": "无法读取数据库,未发送备份。",
|
||||
"privateOnly": "🤖 此机器人仅在私聊中工作。请在私信中打开它。"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ 关闭键盘",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ 确认清除 IP?",
|
||||
"confirmRemoveTGUser": "✅ 确认移除 Telegram 用户?",
|
||||
"confirmToggle": "✅ 确认启用/禁用用户?",
|
||||
"dbBackup": "获取数据库备份",
|
||||
"serverUsage": "服务器使用情况",
|
||||
"getInbounds": "获取入站信息",
|
||||
"depleteSoon": "即将耗尽",
|
||||
"clientUsage": "获取使用情况",
|
||||
"onlines": "在线客户端",
|
||||
"commands": "命令",
|
||||
"dbBackup": "💾 获取数据库备份",
|
||||
"serverUsage": "📊 服务器使用情况",
|
||||
"getInbounds": "🔌 获取入站信息",
|
||||
"depleteSoon": "⚠️ 即将耗尽",
|
||||
"onlines": "🟢 在线客户端",
|
||||
"catServer": "🖥 服务器",
|
||||
"catClients": "👥 客户端",
|
||||
"catTraffic": "🚦 流量",
|
||||
"catMaintenance": "⚙️ 维护",
|
||||
"refresh": "🔄 刷新",
|
||||
"clearIPs": "❌ 清除 IP",
|
||||
"removeTGUser": "❌ 移除 Telegram 用户",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ 确认: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ 确认添加:{{ .Num }}",
|
||||
"limitTraffic": "🚧 流量限制",
|
||||
"getBanLogs": "禁止日志",
|
||||
"allClients": "所有客户",
|
||||
"addClient": "添加客户",
|
||||
"getBanLogs": "📄 禁止日志",
|
||||
"allClients": "👥 所有客户",
|
||||
"addClient": "➕ 添加客户",
|
||||
"submitDisable": "提交为禁用 ☑️",
|
||||
"submitEnable": "提交为启用 ✅",
|
||||
"use_default": "🏷️ 使用默认",
|
||||
"change_email": "⚙️📧 邮箱",
|
||||
"change_comment": "⚙️💬 评论",
|
||||
"ResetAllTraffics": "重置所有流量",
|
||||
"SortedTrafficUsageReport": "排序的流量使用报告"
|
||||
"ResetAllTraffics": "♻️ 重置所有流量",
|
||||
"SortedTrafficUsageReport": "📈 排序的流量使用报告",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 广播",
|
||||
"attachInbound": "关联入站 ({{ .Count }})",
|
||||
"attachDone": "✅ 完成"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 群发已开始。",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "为入站 {{ .Inbound }} 选择一个客户",
|
||||
"chooseInbound": "选择一个入站",
|
||||
"qrCodeForClient": "客户端 {{ .Email }} 的二维码:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "已过期",
|
||||
"fromFirstUse": "自首次使用"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
"uploaded": "已上傳",
|
||||
"expiry": "到期",
|
||||
"totalQuota": "總配額",
|
||||
"individualLinks": "個別連結",
|
||||
"active": "啟用",
|
||||
"inactive": "停用",
|
||||
"unlimited": "無限制",
|
||||
@@ -1830,6 +1829,11 @@
|
||||
"subIncyResolveDnsIp": "DoH 伺服器 IP",
|
||||
"subIncyResolveDnsIpDesc": "用於引導的 DoH 伺服器 IP。",
|
||||
"subIncyGroupRouting": "路由",
|
||||
"tgInlineMissing": "Inline mode is disabled",
|
||||
"tgInlineMissingDesc": "Lists in the bot open through inline search. Enable it in @BotFather with /setinline, otherwise tapping a search button does nothing.",
|
||||
"tgInlineRecheck": "Check again",
|
||||
"tgPrivacyOn": "群組隱私已開啟",
|
||||
"tgPrivacyOnDesc": "此機器人只在私聊中回覆,因此會刻意忽略群組。群組隱私僅決定機器人能否讀取群組訊息,而它並不使用該權限。",
|
||||
"subHostSortMode": "訂閱節點排序",
|
||||
"subHostSortInbound": "入站順序(預設)",
|
||||
"subHostSortHosts": "主機順序",
|
||||
@@ -2486,7 +2490,6 @@
|
||||
}
|
||||
},
|
||||
"tgbot": {
|
||||
"keyboardClosed": "❌ 自定義鍵盤已關閉!",
|
||||
"noResult": "❗ 沒有結果!",
|
||||
"noQuery": "❌ 未找到查詢!請再次使用該命令!",
|
||||
"wentWrong": "❌ 出了點問題!",
|
||||
@@ -2508,10 +2511,6 @@
|
||||
"unknown": "❗ 未知命令",
|
||||
"pleaseChoose": "👇 請選擇:\r\n",
|
||||
"help": "🤖 歡迎使用本機器人!它旨在為您提供來自伺服器的特定資料,並允許您進行必要的修改。\r\n\r\n",
|
||||
"start": "👋 你好,<i>{{ .Firstname }}</i>。\r\n",
|
||||
"welcome": "🤖 歡迎來到 <b>{{ .Hostname }}</b> 管理機器人。\r\n",
|
||||
"status": "✅ 機器人正常執行!",
|
||||
"usage": "❗ 請輸入要搜尋的文字!",
|
||||
"getID": "🆔 您的 ID 為:<code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "要重新啟動 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜尋客戶電子郵件:\r\n<code>/usage [電子郵件]</code>\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n<code>/inbound [備註]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n<code>/usage [電子郵件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
@@ -2520,14 +2519,7 @@
|
||||
"restartFailed": "❗ 操作錯誤。\r\n\r\n<code>錯誤: {{ .Error }}</code>.",
|
||||
"xrayNotRunning": "❗ Xray Core 未運行。",
|
||||
"startDesc": "顯示主選單",
|
||||
"helpDesc": "機器人幫助",
|
||||
"statusDesc": "檢查機器人狀態",
|
||||
"idDesc": "顯示您的 Telegram ID",
|
||||
"usageDesc": "查看客戶端用量:/usage 郵箱",
|
||||
"inboundDesc": "搜尋入站:/inbound 備註(管理員)",
|
||||
"restartDesc": "重啟 Xray 核心(管理員)",
|
||||
"broadcastDesc": "向所有客戶端群發訊息(管理員)",
|
||||
"clearallDesc": "重置所有客戶端流量(管理員)"
|
||||
"statusOk": "✅ 機器人運作正常!"
|
||||
},
|
||||
"messages": {
|
||||
"cpuThreshold": "CPU 使用率為 {{ .Percent }}%,超過閾值 {{ .Threshold }}%",
|
||||
@@ -2571,7 +2563,6 @@
|
||||
"inviteInvalid": "❗ 此邀請連結無效或已被使用。\r\n",
|
||||
"inviteRateLimited": "⏳ 邀請嘗試次數過多,請稍後再試。\r\n",
|
||||
"inviteRateLimitedAdmin": "⚠️ 已封鎖 {{ .User }}(<code>{{ .ID }}</code>)的邀請嘗試:一小時內超過 {{ .Limit }} 次。可能有人在猜測訂閱 ID。",
|
||||
"inviteLink": "🔗 <code>{{ .Email }}</code> 的邀請連結:\r\n<code>{{ .Link }}</code>\r\n\r\n第一個開啟它的人將綁定至此客戶端。\r\n",
|
||||
"exhaustedCount": "🚨 耗盡的 {{ .Type }} 數量:\r\n",
|
||||
"onlinesCount": "🌐 線上客戶:{{ .Count }}\r\n",
|
||||
"disabled": "🛑 禁用:{{ .Disabled }}\r\n",
|
||||
@@ -2580,13 +2571,11 @@
|
||||
"refreshedOn": "\r\n📋🔄 重新整理時間:{{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ 是的",
|
||||
"no": "❌ 否",
|
||||
"received_email": "📧📥 電子郵件已更新。",
|
||||
"received_comment": "💬📥 評論已更新。",
|
||||
"email_prompt": "📧 預設電子郵件: {{ .ClientEmail }}\n\n請輸入您的電子郵件。",
|
||||
"comment_prompt": "💬 預設評論: {{ .ClientComment }}\n\n請輸入您的評論。",
|
||||
"tgid_prompt": "🆔 預設 TG 使用者:{{ .ClientTGID }}\n\n傳送使用者的數字 id 以綁定到此用戶端,或傳送 - / none 以清除。",
|
||||
"cancel": "❌ 程序已取消!\n\n您可以隨時使用 /start 重新開始。 🔄",
|
||||
"error_add_client": "⚠️ 錯誤:\n\n {{ .error }}",
|
||||
"using_default_value": "好的,我會使用預設值。 😊",
|
||||
"incorrect_input": "您的輸入無效。\n短語應連續輸入,不能有空格。\n正確示例: aaaaaa\n錯誤示例: aaa aaa 🚫",
|
||||
"broadcastAskText": "📤 群發:請把要發給客戶的訊息傳給我 — 文字、照片、影片、檔案或整個相簿都可以。",
|
||||
"broadcastAlreadyRunning": "❗ 已有群發正在進行,請等待其完成。",
|
||||
@@ -2610,7 +2599,23 @@
|
||||
"eventNodeDown": "節點 {{ .Name }} 已離線",
|
||||
"eventNodeUp": "節點 {{ .Name }} 已上線",
|
||||
"eventLoginFallback": "來自 {{ .Source }} 的登入失敗",
|
||||
"memoryThreshold": "記憶體使用率 {{ .Percent }}% 超過閾值 {{ .Threshold }}%"
|
||||
"memoryThreshold": "記憶體使用率 {{ .Percent }}% 超過閾值 {{ .Threshold }}%",
|
||||
"inboundsCount": "🔌 Inbounds: {{ .Count }}\r\n",
|
||||
"clientsCount": "👥 Clients: {{ .Count }}\r\n",
|
||||
"category_server": "🖥 伺服器",
|
||||
"category_clients": "👥 用戶端",
|
||||
"category_traffic": "🚦 流量",
|
||||
"category_maintenance": "⚙️ 維護",
|
||||
"inlineHint": "\r\n🔍 The list opens through search: tap Find and start typing.",
|
||||
"subURL": "🔗 Subscription: <code>{{ .URL }}</code>\r\n",
|
||||
"jsonURL": "🧩 JSON: <code>{{ .URL }}</code>\r\n",
|
||||
"inviteHint": "Send this link to the client: the first Telegram account to open it is bound to the client.",
|
||||
"qrLegendHeader": "🔳 QR 碼:",
|
||||
"qrLegendSubscription": "{{ .Code }}. 訂閱",
|
||||
"qrLegendJSON": "{{ .Code }}. JSON 訂閱",
|
||||
"qrLegendLink": "{{ .Code }}. 應用程式連結 {{ .App }}",
|
||||
"backupFailed": "無法讀取資料庫,未傳送備份。",
|
||||
"privateOnly": "🤖 此機器人僅在私聊中運作。請在私訊中開啟它。"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ 關閉鍵盤",
|
||||
@@ -2622,13 +2627,15 @@
|
||||
"confirmClearIps": "✅ 確認清除 IP?",
|
||||
"confirmRemoveTGUser": "✅ 確認移除 Telegram 使用者?",
|
||||
"confirmToggle": "✅ 確認啟用/禁用使用者?",
|
||||
"dbBackup": "獲取資料庫備份",
|
||||
"serverUsage": "伺服器使用情況",
|
||||
"getInbounds": "獲取入站資訊",
|
||||
"depleteSoon": "即將耗盡",
|
||||
"clientUsage": "獲取使用情況",
|
||||
"onlines": "線上客戶端",
|
||||
"commands": "命令",
|
||||
"dbBackup": "💾 獲取資料庫備份",
|
||||
"serverUsage": "📊 伺服器使用情況",
|
||||
"getInbounds": "🔌 獲取入站資訊",
|
||||
"depleteSoon": "⚠️ 即將耗盡",
|
||||
"onlines": "🟢 線上客戶端",
|
||||
"catServer": "🖥 伺服器",
|
||||
"catClients": "👥 用戶端",
|
||||
"catTraffic": "🚦 流量",
|
||||
"catMaintenance": "⚙️ 維護",
|
||||
"refresh": "🔄 重新整理",
|
||||
"clearIPs": "❌ 清除 IP",
|
||||
"removeTGUser": "❌ 移除 Telegram 使用者",
|
||||
@@ -2645,16 +2652,30 @@
|
||||
"confirmNumber": "✅ 確認: {{ .Num }}",
|
||||
"confirmNumberAdd": "✅ 確認新增:{{ .Num }}",
|
||||
"limitTraffic": "🚧 流量限制",
|
||||
"getBanLogs": "禁止日誌",
|
||||
"allClients": "所有客戶",
|
||||
"addClient": "新增客戶",
|
||||
"getBanLogs": "📄 禁止日誌",
|
||||
"allClients": "👥 所有客戶",
|
||||
"addClient": "➕ 新增客戶",
|
||||
"submitDisable": "以停用方式送出 ☑️",
|
||||
"submitEnable": "以啟用方式送出 ✅",
|
||||
"use_default": "🏷️ 使用預設值",
|
||||
"change_email": "⚙️📧 電子郵件",
|
||||
"change_comment": "⚙️💬 評論",
|
||||
"ResetAllTraffics": "重設所有流量",
|
||||
"SortedTrafficUsageReport": "排序過的流量使用報告"
|
||||
"ResetAllTraffics": "♻️ 重設所有流量",
|
||||
"SortedTrafficUsageReport": "📈 排序過的流量使用報告",
|
||||
"restartXray": "♻️ Restart Xray",
|
||||
"back": "⬅️ Back",
|
||||
"hide": "🙈 Hide",
|
||||
"page": "Page {{ .Page }} of {{ .Pages }}",
|
||||
"searchInbounds": "🔍 Find inbound…",
|
||||
"searchClients": "🔍 Find client…",
|
||||
"inlineDisabled": "⚠️ Inline mode is off",
|
||||
"clientCard": "⬅️ Client card",
|
||||
"links": "🔗 Links",
|
||||
"individualLinks": "🔗 Individual links",
|
||||
"qrCode": "🔳 QR code",
|
||||
"broadcast": "📢 廣播",
|
||||
"attachInbound": "關聯入站 ({{ .Count }})",
|
||||
"attachDone": "✅ 完成"
|
||||
},
|
||||
"answers": {
|
||||
"broadcastStarted": "🚀 群發已開始。",
|
||||
@@ -2682,6 +2703,36 @@
|
||||
"chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶",
|
||||
"chooseInbound": "選擇一個入站",
|
||||
"qrCodeForClient": "客戶端 {{ .Email }} 的二維碼:"
|
||||
},
|
||||
"screens": {
|
||||
"panel": "Panel",
|
||||
"main": "Main",
|
||||
"status": "Server",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"client": "Client",
|
||||
"links": "Links",
|
||||
"qr": "QR code",
|
||||
"wizard": "New client",
|
||||
"reports": "Report",
|
||||
"backup": "Backup",
|
||||
"onlines": "Online",
|
||||
"deplete": "Running out",
|
||||
"commands": "Commands",
|
||||
"broadcast": "Broadcast"
|
||||
},
|
||||
"inline": {
|
||||
"noneTitle": "Nothing found",
|
||||
"noneDesc": "Use @BotFather /setinline to enable the list.",
|
||||
"offTitle": "⚠️ Inline mode is disabled for this bot",
|
||||
"offBody": "Lists are browsed in inline mode. Enable it in @BotFather: /setinline, then tap Check again.",
|
||||
"recheck": "Check again",
|
||||
"expired": "已過期",
|
||||
"fromFirstUse": "自首次使用"
|
||||
},
|
||||
"noValue": "—",
|
||||
"notices": {
|
||||
"repeat": "\r\n\r\n🔁 repeated {{ .Count }}× · last {{ .Time }}"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -719,6 +719,19 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
return nil
|
||||
})
|
||||
|
||||
controller.SetTgBotCapsFunc(func() (controller.TgBotCapabilities, error) {
|
||||
caps, err := s.tgbotService.Capabilities()
|
||||
if err != nil {
|
||||
return controller.TgBotCapabilities{}, err
|
||||
}
|
||||
return controller.TgBotCapabilities{
|
||||
InlineEnabled: caps.InlineEnabled,
|
||||
GroupPrivacy: caps.GroupPrivacy,
|
||||
Username: caps.Username,
|
||||
Running: caps.Running,
|
||||
}, nil
|
||||
})
|
||||
|
||||
controller.SetReloadTgbotFunc(func() {
|
||||
enabled, err := s.settingService.GetTgbotEnabled()
|
||||
if err != nil || !enabled {
|
||||
|
||||
@@ -120,6 +120,10 @@ func run(root, outDir string) error {
|
||||
Path: resolveRel(root, "internal/web/service/panel"),
|
||||
StructAllow: setOf("ApiTokenView", "PanelUpdateStatus", "Sponsor", "SponsorList"),
|
||||
},
|
||||
{
|
||||
Path: resolveRel(root, "internal/web/controller"),
|
||||
StructAllow: setOf("TgBotCapabilities"),
|
||||
},
|
||||
{
|
||||
Path: resolveRel(root, "internal/amneziawg"),
|
||||
StructAllow: setOf("ServerSettings"),
|
||||
|
||||
Reference in New Issue
Block a user