New features: TG Telegram robot terminal [One-click configuration] and [Subscription conversion]
This commit is contained in:
parent
e18ce0f23f
commit
dc2f077a91
@ -15,6 +15,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"encoding/json" // 新增:用于 json.Marshal / Unmarshal
|
||||
"net/http" // 新增:用于 http.Client / Transport
|
||||
"crypto/tls" // 新增:用于 tls.Config
|
||||
"os/exec" // 新增:用于 exec.Command(getDomain 等)
|
||||
"path/filepath" // 新增:用于 filepath.Base / Dir(getDomain 用到)
|
||||
|
||||
"x-ui/config"
|
||||
"x-ui/database"
|
||||
@ -31,12 +36,23 @@ import (
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||
// 新增 qrcode 包,用于生成二维码
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
|
||||
// 〔中文注释〕: 新增 TelegramService 接口,用于解耦 Job 和 Telegram Bot 的直接依赖。
|
||||
// 任何实现了 SendMessage(msg string) error 方法的结构体,都可以被认为是 TelegramService。
|
||||
type TelegramService interface {
|
||||
SendMessage(msg string) error
|
||||
SendMessage(msg string) error
|
||||
SendSubconverterSuccess()
|
||||
IsRunning() bool
|
||||
// 您可以根据 server.go 的需要,在这里继续扩展接口
|
||||
// 〔中文注释〕: 将 SendOneClickConfig 方法添加到接口中,这样其他服务可以通过接口来调用它,
|
||||
// 实现了与具体实现 Tgbot 的解耦。
|
||||
SendOneClickConfig(inbound *model.Inbound, inFromPanel bool, chatId int64) error
|
||||
// 新增 GetDomain 方法签名,以满足 server.go 的调用需求
|
||||
GetDomain() (string, error)
|
||||
}
|
||||
|
||||
var (
|
||||
@ -77,16 +93,47 @@ const (
|
||||
)
|
||||
|
||||
type Tgbot struct {
|
||||
inboundService InboundService
|
||||
settingService SettingService
|
||||
serverService ServerService
|
||||
xrayService XrayService
|
||||
lastStatus *Status
|
||||
inboundService *InboundService
|
||||
settingService *SettingService
|
||||
serverService *ServerService
|
||||
xrayService *XrayService
|
||||
lastStatus *Status
|
||||
}
|
||||
|
||||
// 【新增方法】: 用于从外部注入 ServerService 实例
|
||||
func (t *Tgbot) SetServerService(s *ServerService) {
|
||||
t.serverService = s
|
||||
}
|
||||
|
||||
// 配合目前 main.go 代码结构实践。
|
||||
func (t *Tgbot) SetInboundService(s *InboundService) {
|
||||
t.inboundService = s
|
||||
}
|
||||
|
||||
// 〔中文注释〕: 在这里添加新的构造函数
|
||||
// NewTgBot 创建并返回一个完全初始化的 Tgbot 实例。
|
||||
// 这个函数确保了所有服务依赖项都被正确注入,避免了空指针问题。
|
||||
func NewTgBot(
|
||||
inboundService *InboundService,
|
||||
settingService *SettingService,
|
||||
serverService *ServerService,
|
||||
xrayService *XrayService,
|
||||
lastStatus *Status,
|
||||
) *Tgbot {
|
||||
return &Tgbot{
|
||||
inboundService: inboundService,
|
||||
settingService: settingService,
|
||||
serverService: serverService,
|
||||
xrayService: xrayService,
|
||||
lastStatus: lastStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
func (t *Tgbot) NewTgbot() *Tgbot {
|
||||
return new(Tgbot)
|
||||
}
|
||||
*/
|
||||
|
||||
func (t *Tgbot) I18nBot(name string, params ...string) string {
|
||||
return locale.I18n(locale.Bot, name, params...)
|
||||
@ -98,7 +145,7 @@ func (t *Tgbot) GetHashStorage() *global.HashStorage {
|
||||
|
||||
func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
// Initialize localizer
|
||||
err := locale.InitLocalizer(i18nFS, &t.settingService)
|
||||
err := locale.InitLocalizer(i18nFS, t.settingService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -160,6 +207,8 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
{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: "oneclick", Description: "🚀 一键配置节点 (有可选项)"},
|
||||
{Command: "subconverter", Description: "🔄 检测或安装订阅转换"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@ -499,6 +548,23 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
|
||||
} else {
|
||||
handleUnknownCommand()
|
||||
}
|
||||
// 【新增代码】: 处理 /oneclick 指令
|
||||
case "oneclick":
|
||||
onlyMessage = true
|
||||
if isAdmin {
|
||||
t.sendOneClickOptions(chatId)
|
||||
} else {
|
||||
handleUnknownCommand()
|
||||
}
|
||||
|
||||
// 【新增代码】: 处理 /subconverter 指令
|
||||
case "subconverter":
|
||||
onlyMessage = true
|
||||
if isAdmin {
|
||||
t.checkAndInstallSubconverter(chatId)
|
||||
} else {
|
||||
handleUnknownCommand()
|
||||
}
|
||||
default:
|
||||
handleUnknownCommand()
|
||||
}
|
||||
@ -1637,7 +1703,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
for _, valid_emails := range valid_emails {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails)
|
||||
if err != nil {
|
||||
@ -1660,6 +1726,43 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove())
|
||||
|
||||
}
|
||||
|
||||
// 【新增代码】: 在这里处理新按钮的回调
|
||||
case "oneclick_options":
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, "请选择配置类型...")
|
||||
t.sendOneClickOptions(chatId)
|
||||
|
||||
case "oneclick_reality":
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, "🚀 正在创建 Vless + TCP + Reality 节点...")
|
||||
t.SendMsgToTgbot(chatId, "🚀 正在远程创建【Vless + TCP + Reality】节点,请稍候...")
|
||||
t.remoteCreateOneClickInbound("reality", chatId)
|
||||
|
||||
case "oneclick_tls":
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, "🛡️ 正在创建 Vless Encryption + XHTTP + TLS 节点...")
|
||||
t.SendMsgToTgbot(chatId, "🛡️ 正在远程创建【Vless Encryption + XHTTP + TLS】节点,请稍候...")
|
||||
t.remoteCreateOneClickInbound("tls", chatId)
|
||||
|
||||
case "subconverter_install":
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, "🔄 正在检查服务...")
|
||||
t.checkAndInstallSubconverter(chatId)
|
||||
|
||||
case "confirm_sub_install":
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, "✅ 指令已发送")
|
||||
t.SendMsgToTgbot(chatId, "【订阅转换】模块正在后台安装,大约需要1-2分钟,完成后将再次通知您。")
|
||||
err := t.serverService.InstallSubconverter()
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, fmt.Sprintf("发送安装指令失败: %v", err))
|
||||
}
|
||||
|
||||
case "cancel_sub_install":
|
||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, "已取消")
|
||||
t.SendMsgToTgbot(chatId, "已取消【订阅转换】安装操作。")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1846,6 +1949,11 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
||||
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("tgbot.buttons.oneClick")).WithCallbackData(t.encodeQuery("oneclick_options")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.subconverter")).WithCallbackData(t.encodeQuery("subconverter_install")),
|
||||
),
|
||||
// TODOOOOOOOOOOOOOO: Add restart button here.
|
||||
)
|
||||
numericKeyboardClient := tu.InlineKeyboard(
|
||||
@ -2993,3 +3101,723 @@ func (t *Tgbot) SendMessage(msg string) error {
|
||||
t.SendMsgToTgbotAdmins(msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 【新增函数】: 发送【一键配置】的选项按钮给用户
|
||||
func (t *Tgbot) sendOneClickOptions(chatId int64) {
|
||||
optionsKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton("🚀 Vless + TCP + Reality + Vision").WithCallbackData(t.encodeQuery("oneclick_reality")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton("🛡️ Vless Encryption + XHTTP + TLS").WithCallbackData(t.encodeQuery("oneclick_tls")),
|
||||
),
|
||||
)
|
||||
t.SendMsgToTgbot(chatId, "请选择您要创建的【一键配置】类型:", optionsKeyboard)
|
||||
}
|
||||
|
||||
// 【新增函数】: 检查并安装【订阅转换】
|
||||
func (t *Tgbot) checkAndInstallSubconverter(chatId int64) {
|
||||
domain, err := t.getDomain()
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, fmt.Sprintf("❌ 操作失败:%v", err))
|
||||
return
|
||||
}
|
||||
subConverterUrl := fmt.Sprintf("https://%s:15268", domain)
|
||||
|
||||
t.SendMsgToTgbot(chatId, fmt.Sprintf("正在检测服务状态...\n地址: `%s`", subConverterUrl))
|
||||
|
||||
go func() {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 3 * time.Second}
|
||||
_, err := client.Get(subConverterUrl)
|
||||
|
||||
if err == nil {
|
||||
t.SendMsgToTgbot(chatId, fmt.Sprintf("✅ 服务已存在!\n\n您可以直接通过以下地址访问:\n`%s`", subConverterUrl))
|
||||
} else {
|
||||
confirmKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton("✅ 是,立即安装").WithCallbackData("confirm_sub_install"),
|
||||
tu.InlineKeyboardButton("❌ 否,取消").WithCallbackData("cancel_sub_install"),
|
||||
),
|
||||
)
|
||||
t.SendMsgToTgbot(chatId, "⚠️ 服务检测失败,可能尚未安装。\n\n您想现在执行安装指令吗?\n\n**【重要】**请确保服务器防火墙已放行 `8000` 和 `15268` 端口。", confirmKeyboard)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 远程创建【一键配置】入站,增加一个 type 参数
|
||||
func (t *Tgbot) remoteCreateOneClickInbound(configType string, chatId int64) {
|
||||
var err error
|
||||
var newInbound *model.Inbound
|
||||
var ufwWarning string // 新增变量来捕获警告信息
|
||||
|
||||
if configType == "reality" {
|
||||
newInbound, ufwWarning, err = t.buildRealityInbound()
|
||||
} else if configType == "tls" {
|
||||
newInbound, ufwWarning, err = t.buildTlsInbound()
|
||||
} else {
|
||||
err = errors.New("未知的配置类型")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, fmt.Sprintf("❌ 远程创建失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 〔中文注释〕: 创建一个 InboundService 实例,并将当前的 Tgbot 实例 (t) 作为 tgService 注入进去。
|
||||
inboundService := InboundService{}
|
||||
inboundService.SetTelegramService(t) // 将当前的 bot 实例注入
|
||||
|
||||
createdInbound, _, err := inboundService.AddInbound(newInbound)
|
||||
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, fmt.Sprintf("❌ 远程创建失败: 保存入站时出错: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("TG 机器人远程创建入站 %s 成功!", createdInbound.Remark)
|
||||
|
||||
// 【新增功能】:如果端口放行失败,发送警告
|
||||
if ufwWarning != "" {
|
||||
t.SendMsgToTgbot(chatId, ufwWarning)
|
||||
} // END NEW LOGIC
|
||||
|
||||
// 【调用 TG Bot 专属的通知方法】
|
||||
// inFromPanel 设置为 false,表示这是来自 TG 机器人的操作
|
||||
// 之前 SendOneClickConfig 的 inbound 参数是 *model.Inbound,所以我们传入 createdInbound
|
||||
// 将当前的 chatId 传入,确保配置消息发送给发起指令的用户
|
||||
err = t.SendOneClickConfig(createdInbound, false, chatId)
|
||||
if err != nil {
|
||||
// 如果发送通知失败,给用户一个提示,但不要中断流程
|
||||
t.SendMsgToTgbot(chatId, fmt.Sprintf("⚠️ 入站创建成功,但通知消息发送失败: %v", err))
|
||||
logger.Errorf("TG Bot: 远程创建入站成功,但发送通知失败: %v", err)
|
||||
} else {
|
||||
// 成功发送二维码/配置消息后,再给用户一个确认提示
|
||||
t.SendMsgToTgbot(chatId, "✅ **入站已创建,【二维码/配置链接】已发送至管理员私信。**")
|
||||
}
|
||||
// 【新增功能】:发送用法说明消息
|
||||
// 使用 ** 粗体标记,并使用多行字符串确保换行显示。
|
||||
usageMessage := `**用法说明:**
|
||||
该功能已自动生成现今比较主流的入站协议,简单/直接,不用慢慢配置。
|
||||
并随机分配一个可用端口,请确保此端口放行,生成后请直接复制【**链接地址**】。
|
||||
TG端 的【一键配置】生成功能,与后台 Web端 类似,跟【入站】的数据是打通的。
|
||||
你可以在一键创建后于列表中,手动查看/复制或编辑详细信息,以便添加其他参数。`
|
||||
|
||||
t.SendMsgToTgbot(chatId, usageMessage)
|
||||
}
|
||||
|
||||
// 【新增函数】: 构建 Reality 配置对象 (1:1 复刻自 inbounds.html)
|
||||
func (t *Tgbot) buildRealityInbound() (*model.Inbound, string, error) {
|
||||
keyPairMsg, err := t.serverService.GetNewX25519Cert()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("获取 Reality 密钥对失败: %v", err)
|
||||
}
|
||||
uuidMsg, err := t.serverService.GetNewUUID()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("获取 UUID 失败: %v", err)
|
||||
}
|
||||
|
||||
keyPair := keyPairMsg.(map[string]any)
|
||||
privateKey, publicKey := keyPair["privateKey"].(string), keyPair["publicKey"].(string)
|
||||
uuid := uuidMsg["uuid"]
|
||||
remark := t.randomString(8, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||
port := 10000 + common.RandomInt(55535 - 10000 + 1)
|
||||
|
||||
var ufwWarning string = "" // NEW
|
||||
|
||||
// 【新增功能】:调用 ufw 放行端口
|
||||
if err := t.openPortWithUFW(port); err != nil {
|
||||
// 【核心修改】:如果端口放行失败,不中断入站创建流程,但生成警告信息
|
||||
logger.Warningf("自动放行端口 %d 失败: %v", port, err)
|
||||
ufwWarning = fmt.Sprintf("⚠️ **警告:端口放行失败**\n\n自动执行 `ufw allow %d` 命令失败,入站创建流程已继续,但请务必**手动**在您的 VPS 上放行端口 `%d`,否则服务将无法访问。失败详情:%v", port, port, err)
|
||||
} // END NEW LOGIC
|
||||
|
||||
// 按照要求格式:inbound-端口号
|
||||
tag := fmt.Sprintf("inbound-%d", port)
|
||||
|
||||
realityDests := []string{"tesla.com:443", "sega.com:443", "apple.com:443", "icloud.com:443", "lovelive-anime.jp:443", "meta.com:443"}
|
||||
randomDest := realityDests[common.RandomInt(len(realityDests))]
|
||||
randomSni := strings.Split(randomDest, ":")[0]
|
||||
shortIds := t.generateShortIds()
|
||||
|
||||
// Settings (clients + decryption + fallbacks)
|
||||
settings, _ := json.Marshal(map[string]any{
|
||||
"clients": []map[string]any{{
|
||||
"id": uuid, // 客户端 UUID
|
||||
"flow": "xtls-rprx-vision", // JS 中指定的 flow
|
||||
"email": remark,
|
||||
"level": 0,
|
||||
"enable": true,
|
||||
}},
|
||||
"decryption": "none",
|
||||
"fallbacks": []any{}, // 保留空数组(与前端一致)
|
||||
})
|
||||
|
||||
// StreamSettings => reality
|
||||
streamSettings, _ := json.Marshal(map[string]any{
|
||||
"network": "tcp",
|
||||
"security": "reality",
|
||||
"realitySettings": map[string]any{
|
||||
"show": false, // 前端 show: false
|
||||
"target": randomDest, // e.g. "apple.com:443"
|
||||
"xver": 0,
|
||||
"serverNames": []string{randomSni, "www." + randomSni},
|
||||
// 注意:realitySettings.settings 是一个对象(map),不是数组
|
||||
"settings": map[string]any{
|
||||
"publicKey": publicKey,
|
||||
"spiderX": "/", // 前端写了 spiderX: "/"
|
||||
"mldsa65Verify": "",
|
||||
},
|
||||
"privateKey": privateKey,
|
||||
"maxClientVer": "",
|
||||
"minClientVer": "",
|
||||
"maxTimediff": 0,
|
||||
"mldsa65Seed": "", // 一般留空(JS 注释)
|
||||
"shortIds": shortIds, // 传入的短 id 列表
|
||||
},
|
||||
// TCP 子对象
|
||||
"tcpSettings": map[string]any{
|
||||
"acceptProxyProtocol": false,
|
||||
"header": map[string]any{
|
||||
"type": "none",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// sniffing 完整保留(enabled + destOverride + metadataOnly + routeOnly)
|
||||
sniffing, _ := json.Marshal(map[string]any{
|
||||
"enabled": true,
|
||||
"destOverride": []string{"http", "tls", "quic", "fakedns"},
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false,
|
||||
})
|
||||
|
||||
// 返回 model.Inbound —— 请根据你项目中的 model.Inbound 增减字段(此处包含常见字段)
|
||||
return &model.Inbound{
|
||||
UserId: 1, // 示例:创建者/系统用户 ID,如需动态请替换
|
||||
Remark: remark,
|
||||
Enable: true,
|
||||
Listen: "", // 对应前端 listen: ''
|
||||
Port: port,
|
||||
Tag: tag,
|
||||
Protocol: "vless",
|
||||
// 如果你的 model.Inbound 有这些字段(前端 data 也包含),可以设置或保持默认
|
||||
ExpiryTime: 0, // 前端 expiryTime: 0
|
||||
DeviceLimit: 0, // 前端 deviceLimit: 0
|
||||
Settings: string(settings),
|
||||
StreamSettings: string(streamSettings),
|
||||
Sniffing: string(sniffing),
|
||||
}, ufwWarning, nil // MODIFIED RETURN
|
||||
}
|
||||
|
||||
// 【新增函数】: 构建 TLS 配置对象 (1:1 复刻自 inbounds.html)
|
||||
func (t *Tgbot) buildTlsInbound() (*model.Inbound, string, error) { // 更改签名
|
||||
encMsg, err := t.serverService.GetNewVlessEnc()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("获取 VLESS 加密配置失败: %v", err)
|
||||
}
|
||||
uuidMsg, err := t.serverService.GetNewUUID()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("获取 UUID 失败: %v", err)
|
||||
}
|
||||
|
||||
var decryption, encryption string
|
||||
|
||||
// 确认顶层类型是 map[string]interface{}
|
||||
encMsgMap, ok := encMsg.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("VLESS 加密配置格式不正确: 期望得到 map[string]interface {},但收到了 %T", encMsg)
|
||||
}
|
||||
|
||||
// 从顶层 map 中直接获取 "auths" 键的值
|
||||
authsVal, found := encMsgMap["auths"]
|
||||
|
||||
if !found {
|
||||
return nil, "", errors.New("VLESS 加密配置 auths 格式不正确: 未能在响应中找到 'auths' 数组")
|
||||
}
|
||||
|
||||
// 将 auths 的值断言为正确的类型 []map[string]string
|
||||
// 这是因为 server.go 中的 GetNewVlessEnc 明确返回这个类型。
|
||||
auths, ok := authsVal.([]map[string]string)
|
||||
if !ok {
|
||||
// 如果断言失败,则意味着 auths 数组的内部元素类型不匹配
|
||||
return nil, "", fmt.Errorf("VLESS 加密配置 auths 格式不正确: 'auths' 数组的内部元素类型应为 map[string]string,但收到了 %T", authsVal)
|
||||
}
|
||||
|
||||
// 遍历 auths 数组寻找 ML-KEM-768
|
||||
for _, auth := range auths {
|
||||
// 现在 auth 已经是 map[string]string 类型,可以直接安全访问
|
||||
if label, ok2 := auth["label"]; ok2 && label == "ML-KEM-768, Post-Quantum" {
|
||||
decryption = auth["decryption"]
|
||||
encryption = auth["encryption"]
|
||||
break // 找到后跳出循环
|
||||
}
|
||||
}
|
||||
|
||||
if decryption == "" || encryption == "" {
|
||||
return nil, "", errors.New("未能在 auths 数组中找到 ML-KEM-768 加密密钥,请检查 Xray 版本")
|
||||
}
|
||||
|
||||
domain, err := t.getDomain()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
uuid := uuidMsg["uuid"]
|
||||
remark := t.randomString(8, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||
allowedPorts := []int{2053, 2083, 2087, 2096, 8443}
|
||||
port := allowedPorts[common.RandomInt(len(allowedPorts))]
|
||||
|
||||
var ufwWarning string = "" // NEW
|
||||
|
||||
// 【新增功能】:调用 ufw 放行端口
|
||||
if err := t.openPortWithUFW(port); err != nil {
|
||||
// 【核心修改】:如果端口放行失败,不中断入站创建流程,但生成警告信息
|
||||
logger.Warningf("自动放行端口 %d 失败: %v", port, err)
|
||||
ufwWarning = fmt.Sprintf("⚠️ **警告:端口放行失败**\n\n自动执行 `ufw allow %d` 命令失败,入站创建流程已继续,但请务必**手动**在您的 VPS 上放行端口 `%d`,否则服务将无法访问。失败详情:%v", port, port, err)
|
||||
} // END NEW LOGIC
|
||||
|
||||
// 按照要求格式:inbound-端口号
|
||||
tag := fmt.Sprintf("inbound-%d", port)
|
||||
path := "/" + t.randomString(8, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
||||
certPath := fmt.Sprintf("/root/cert/%s/fullchain.pem", domain)
|
||||
keyPath := fmt.Sprintf("/root/cert/%s/privkey.pem", domain)
|
||||
|
||||
// Settings: clients + decryption + encryption + selectedAuth
|
||||
settings, _ := json.Marshal(map[string]any{
|
||||
"clients": []map[string]any{{
|
||||
"id": uuid,
|
||||
"flow": "", // JS 中 flow: ""
|
||||
"email": remark,
|
||||
"level": 0,
|
||||
"password": "", // JS 中 password: ""
|
||||
"enable": true,
|
||||
}},
|
||||
"decryption": decryption, // 从 API 获取
|
||||
"encryption": encryption, // 从 API 获取(新增)
|
||||
"selectedAuth": "ML-KEM-768, Post-Quantum", // 前端硬编码选择项
|
||||
})
|
||||
|
||||
// streamSettings:network=xhttp, security=tls, tlsSettings + xhttpSettings
|
||||
streamSettings, _ := json.Marshal(map[string]any{
|
||||
"network": "xhttp",
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]any{
|
||||
"alpn": []string{"h2", "http/1.1"},
|
||||
"certificates": []map[string]any{{
|
||||
"buildChain": false,
|
||||
"certificateFile": certPath,
|
||||
"keyFile": keyPath,
|
||||
"oneTimeLoading": false,
|
||||
"usage": "encipherment",
|
||||
}},
|
||||
"cipherSuites": "",
|
||||
"disableSystemRoot": false,
|
||||
"echForceQuery": "none",
|
||||
"echServerKeys": "",
|
||||
"enableSessionResumption": false,
|
||||
"maxVersion": "1.3",
|
||||
"minVersion": "1.2",
|
||||
"rejectUnknownSni": false,
|
||||
"serverName": domain,
|
||||
"verifyPeerCertInNames": []string{"dns.google", "cloudflare-dns.com"},
|
||||
},
|
||||
"xhttpSettings": map[string]any{
|
||||
"headers": map[string]any{}, // 可按需填充(JS 为 {})
|
||||
"host": "", // 前端留空
|
||||
"mode": "packet-up",
|
||||
"noSSEHeader": false,
|
||||
"path": path, // 随机 8 位路径
|
||||
"scMaxBufferedPosts": 30,
|
||||
"scMaxEachPostBytes": "1000000",
|
||||
"scStreamUpServerSecs": "20-80",
|
||||
"xPaddingBytes": "100-1000",
|
||||
},
|
||||
})
|
||||
|
||||
// sniffing: 与前端一致(enabled:false)
|
||||
sniffing, _ := json.Marshal(map[string]any{
|
||||
"enabled": false,
|
||||
"destOverride": []string{"http", "tls", "quic", "fakedns"},
|
||||
"metadataOnly": false,
|
||||
"routeOnly": false,
|
||||
})
|
||||
|
||||
return &model.Inbound{
|
||||
UserId: 1,
|
||||
Remark: remark,
|
||||
Enable: true,
|
||||
Listen: "",
|
||||
Port: port,
|
||||
Tag: tag,
|
||||
Protocol: "vless",
|
||||
ExpiryTime: 0,
|
||||
DeviceLimit: 0,
|
||||
Settings: string(settings),
|
||||
StreamSettings: string(streamSettings),
|
||||
Sniffing: string(sniffing),
|
||||
}, ufwWarning, nil // MODIFIED RETURN
|
||||
}
|
||||
|
||||
// 发送【一键配置】的专属消息
|
||||
func (t *Tgbot) SendOneClickConfig(inbound *model.Inbound, inFromPanel bool, targetChatId int64) error {
|
||||
var link string
|
||||
var err error
|
||||
|
||||
var streamSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.StreamSettings), &streamSettings)
|
||||
|
||||
// --- 1. 确定链接和协议类型 ---
|
||||
var linkType string
|
||||
if security, ok := streamSettings["security"].(string); ok {
|
||||
if security == "reality" {
|
||||
link, err = t.generateRealityLink(inbound)
|
||||
linkType = "VLESS + TCP + Reality" // 协议类型
|
||||
} else if security == "tls" {
|
||||
link, err = t.generateTlsLink(inbound)
|
||||
linkType = "Vless Encryption + XHTTP + TLS" // 协议类型
|
||||
} else {
|
||||
return fmt.Errorf("未知的入站 security 类型: %s", security)
|
||||
}
|
||||
} else {
|
||||
return errors.New("无法解析 streamSettings 中的 security 字段")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 尝试生成二维码,如果失败,则 qrCodeBytes 为 nil 或空
|
||||
qrCodeBytes, err := qrcode.Encode(link, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
logger.Warningf("生成二维码失败,将尝试发送纯文本链接: %v", err)
|
||||
qrCodeBytes = nil // 确保 qrCodeBytes 为 nil,用于后续判断
|
||||
}
|
||||
|
||||
// --- 2. 获取生成时间 ---
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
|
||||
// --- 3. 构造包含所有信息并严格遵循格式的描述消息 ---
|
||||
baseCaption := fmt.Sprintf(
|
||||
"入站备注:\n\n`%s`\n\n用户 Email:\n\n`%s`\n\n协议类型:\n\n`%s`\n\n设备限制:0(无限制),\n\n生成时间:\n\n`%s`",
|
||||
inbound.Remark,
|
||||
inbound.Remark, // 默认使用 Remark 作为 Email
|
||||
linkType,
|
||||
now,
|
||||
)
|
||||
|
||||
var caption string
|
||||
if inFromPanel {
|
||||
caption = fmt.Sprintf("✅ **面板【一键配置】入站已创建成功!**\n\n%s\n\n👇 **可点击下方链接直接【复制/导入】** 👇", baseCaption)
|
||||
} else {
|
||||
caption = fmt.Sprintf("✅ **TG端 远程【一键配置】创建成功!**\n\n%s\n\n👇 **可点击下方链接直接【复制/导入】** 👇", baseCaption)
|
||||
}
|
||||
// 发送主消息(包含描述和二维码)
|
||||
if len(qrCodeBytes) > 0 {
|
||||
// 尝试发送图片消息
|
||||
photoParams := tu.Photo(
|
||||
tu.ID(targetChatId),
|
||||
tu.FileFromBytes(qrCodeBytes, "qrcode.png"),
|
||||
).WithCaption(caption).WithParseMode(telego.ModeMarkdown)
|
||||
|
||||
if _, err := bot.SendPhoto(context.Background(), photoParams); err != nil {
|
||||
logger.Warningf("发送带二维码的 TG 消息给 %d 失败: %v", targetChatId, err)
|
||||
// 如果图片发送失败,回退到发送纯文本描述
|
||||
t.SendMsgToTgbot(targetChatId, caption)
|
||||
}
|
||||
} else {
|
||||
// 如果二维码生成失败,直接发送纯文本描述
|
||||
t.SendMsgToTgbot(targetChatId, caption)
|
||||
}
|
||||
|
||||
// 链接单独发送,不带任何 Markdown 格式。
|
||||
// 这将确保 Telegram 客户端可以将其正确识别为可点击的 vless:// 链接。
|
||||
t.SendMsgToTgbot(targetChatId, link)
|
||||
|
||||
|
||||
// 历史记录保存逻辑
|
||||
linkType = "vless_reality"
|
||||
if strings.Contains(link, "security=tls") {
|
||||
linkType = "vless_tls_encryption"
|
||||
}
|
||||
t.saveLinkToHistory(linkType, link)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 【新增辅助函数】: 生成 Reality 链接
|
||||
func (t *Tgbot) generateRealityLink(inbound *model.Inbound) (string, error) {
|
||||
var settings map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
clients, _ := settings["clients"].([]interface{})
|
||||
client := clients[0].(map[string]interface{})
|
||||
uuid := client["id"].(string)
|
||||
|
||||
var streamSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.StreamSettings), &streamSettings)
|
||||
realitySettings := streamSettings["realitySettings"].(map[string]interface{})
|
||||
serverNames := realitySettings["serverNames"].([]interface{})
|
||||
sni := serverNames[0].(string)
|
||||
|
||||
// publicKey 在 realitySettings 下的 settings 子对象中
|
||||
settingsMap, ok := realitySettings["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errors.New("realitySettings中缺少settings子对象")
|
||||
}
|
||||
publicKey, ok := settingsMap["publicKey"].(string)
|
||||
if !ok {
|
||||
// 再次检查,以防结构有变,但主要依赖 settingsMap
|
||||
return "", errors.New("publicKey字段缺失或格式错误 (可能在settings子对象中)")
|
||||
}
|
||||
|
||||
shortIdsInterface := realitySettings["shortIds"].([]interface{})
|
||||
// 确保 shortIdsInterface 不为空,否则可能 panic
|
||||
if len(shortIdsInterface) == 0 {
|
||||
return "", errors.New("无法生成 Reality 链接:Short IDs 列表为空")
|
||||
}
|
||||
sid := shortIdsInterface[common.RandomInt(len(shortIdsInterface))].(string)
|
||||
|
||||
domain, err := t.getDomain()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// ---------------------- URL 编码 ----------------------
|
||||
// 必须对查询参数的值(pbk, sni, sid)
|
||||
// Go 标准库中的 net/url.QueryEscape 会处理 Base64 字符串中的 + / 等字符。
|
||||
escapedPublicKey := url.QueryEscape(publicKey)
|
||||
escapedSni := url.QueryEscape(sni)
|
||||
escapedSid := url.QueryEscape(sid)
|
||||
escapedRemark := url.QueryEscape(inbound.Remark)
|
||||
|
||||
return fmt.Sprintf("vless://%s@%s:%d?type=tcp&encryption=none&security=reality&pbk=%s&fp=chrome&sni=%s&sid=%s&spx=%%2F&flow=xtls-rprx-vision#%s-%s",
|
||||
uuid, domain, inbound.Port, escapedPublicKey, escapedSni, escapedSid, escapedRemark, escapedRemark), nil
|
||||
}
|
||||
|
||||
// 【新增辅助函数】: 生成 TLS 链接
|
||||
func (t *Tgbot) generateTlsLink(inbound *model.Inbound) (string, error) {
|
||||
var settings map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
clients, _ := settings["clients"].([]interface{})
|
||||
client := clients[0].(map[string]interface{})
|
||||
uuid := client["id"].(string)
|
||||
encryption := settings["encryption"].(string)
|
||||
|
||||
var streamSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.StreamSettings), &streamSettings)
|
||||
tlsSettings := streamSettings["tlsSettings"].(map[string]interface{})
|
||||
sni := tlsSettings["serverName"].(string)
|
||||
|
||||
domain, err := t.getDomain()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 链接格式简化,根据您的前端代码,xhttp 未在链接中体现 path
|
||||
return fmt.Sprintf("vless://%s@%s:%d?type=tcp&encryption=%s&security=tls&fp=chrome&alpn=http%%2F1.1&sni=%s&flow=xtls-rprx-vision#%s-%s",
|
||||
uuid, domain, inbound.Port, encryption, sni, inbound.Remark, inbound.Remark), nil
|
||||
}
|
||||
|
||||
// 【新增辅助函数】: 发送【订阅转换】安装成功的通知
|
||||
func (t *Tgbot) SendSubconverterSuccess() {
|
||||
// func (t *Tgbot) SendSubconverterSuccess(targetChatId int64) {
|
||||
domain, err := t.getDomain()
|
||||
if err != nil {
|
||||
domain = "[您的面板域名]"
|
||||
}
|
||||
|
||||
msgText := fmt.Sprintf(
|
||||
"🎉 **恭喜!【订阅转换】模块已成功安装!**\n\n"+
|
||||
"您现在可以使用以下地址访问 Web 界面:\n\n"+
|
||||
"🔗 **登录地址**: `https://%s:15268`\n\n"+
|
||||
"默认用户名: `admin`\n"+
|
||||
"默认 密码: `123456`\n\n"+
|
||||
"可登录订阅转换后台修改您的密码!",
|
||||
domain,
|
||||
)
|
||||
t.SendMsgToTgbotAdmins(msgText)
|
||||
// t.SendMsgToTgbot(targetChatId, msgText)
|
||||
}
|
||||
|
||||
// 【新增辅助函数】: 获取域名(shell 方案)
|
||||
func (t *Tgbot) getDomain() (string, error) {
|
||||
cmd := exec.Command("/usr/local/x-ui/x-ui", "setting", "-getCert", "true")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", errors.New("执行命令获取证书路径失败,请确保已为面板配置 SSL 证书")
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
certLine := ""
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "cert:") {
|
||||
certLine = line
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if certLine == "" {
|
||||
return "", errors.New("无法从 x-ui 命令输出中找到证书路径")
|
||||
}
|
||||
|
||||
certPath := strings.TrimSpace(strings.TrimPrefix(certLine, "cert:"))
|
||||
if certPath == "" {
|
||||
return "", errors.New("证书路径为空,请确保已为面板配置 SSL 证书")
|
||||
}
|
||||
|
||||
domain := filepath.Base(filepath.Dir(certPath))
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
// 【新增辅助函数】: 1:1 复刻自 inbounds.html
|
||||
func (t *Tgbot) generateShortIds() []string {
|
||||
chars := "0123456789abcdef"
|
||||
lengths := []int{2, 4, 6, 8, 10, 12, 14, 16}
|
||||
shortIds := make([]string, len(lengths))
|
||||
for i, length := range lengths {
|
||||
shortIds[i] = t.randomString(length, chars)
|
||||
}
|
||||
return shortIds
|
||||
}
|
||||
|
||||
// 【新增辅助函数】: 随机字符串生成器
|
||||
func (t *Tgbot) randomString(length int, charset string) string {
|
||||
bytes := make([]byte, length)
|
||||
for i := range bytes {
|
||||
randomIndex, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
bytes[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
// 【新增辅助函数】: 保存链接历史到数据库
|
||||
func (t *Tgbot) saveLinkToHistory(linkType string, link string) {
|
||||
history := &database.LinkHistory{
|
||||
Type: linkType,
|
||||
Link: link,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := database.AddLinkHistory(history); err != nil {
|
||||
logger.Warningf("保存链接历史到数据库失败: %v", err)
|
||||
}
|
||||
database.Checkpoint()
|
||||
}
|
||||
|
||||
// 需要的 imports(合并到文件顶部):
|
||||
// "fmt"
|
||||
// "strings"
|
||||
// th "github.com/mymmrac/telego/telegohandler"
|
||||
// tu "github.com/mymmrac/telego/telegoutil"
|
||||
// "github.com/mymmrac/telego"
|
||||
|
||||
func (t *Tgbot) handleCallbackQuery(ctx *th.Context, cq telego.CallbackQuery) error {
|
||||
// 1) 确保 Message 可访问 —— 注意必须调用 cq.Message.Message() 而不是直接访问 .Message
|
||||
if cq.Message == nil || cq.Message.Message == nil {
|
||||
_ = ctx.Bot().AnswerCallbackQuery(ctx, tu.CallbackQuery(cq.ID).WithText("消息对象不存在"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 关键修正:这里要调用方法 Message()
|
||||
msg := cq.Message.Message() // <- 调用方法,返回 *telego.Message
|
||||
// 现在 msg 是 *telego.Message,可以访问 Chat / MessageID
|
||||
chatIDInt64 := msg.Chat.ID
|
||||
messageID := msg.MessageID
|
||||
|
||||
// 解码回调数据(沿用你已有函数)
|
||||
data, err := t.decodeQuery(cq.Data)
|
||||
if err != nil {
|
||||
_ = ctx.Bot().AnswerCallbackQuery(ctx, tu.CallbackQuery(cq.ID).WithText("回调数据解析失败"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 移除内联键盘(telegoutil 构造 params)
|
||||
if _, err := ctx.Bot().EditMessageReplyMarkup(ctx, tu.EditMessageReplyMarkup(tu.ID(chatIDInt64), messageID, nil)); err != nil {
|
||||
logger.Warningf("TG Bot: 移除内联键盘失败: %v", err)
|
||||
}
|
||||
|
||||
// ---------- oneclick_ 分支 ----------
|
||||
if strings.HasPrefix(data, "oneclick_") {
|
||||
configType := strings.TrimPrefix(data, "oneclick_")
|
||||
|
||||
// 注意:不要把无返回值函数当作表达式使用,直接调用即可
|
||||
t.SendMsgToTgbot(chatIDInt64, fmt.Sprintf("🛠️ 正在为您远程创建 %s 配置,请稍候...", strings.ToUpper(configType)))
|
||||
t.remoteCreateOneClickInbound(configType, chatIDInt64)
|
||||
|
||||
_ = ctx.Bot().AnswerCallbackQuery(ctx, tu.CallbackQuery(cq.ID).WithText("配置已创建,请查收管理员私信。"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- confirm_sub_install 分支 ----------
|
||||
if data == "confirm_sub_install" {
|
||||
t.SendMsgToTgbot(chatIDInt64, "🛠️ **已接收到订阅转换安装指令,** 后台正在异步执行...")
|
||||
|
||||
if err := t.serverService.InstallSubconverter(); err != nil {
|
||||
// 直接调用发送函数(无返回值)
|
||||
t.SendMsgToTgbot(chatIDInt64, fmt.Sprintf("❌ **安装指令启动失败:**\n`%v`", err))
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatIDInt64, "✅ **安装指令已成功发送到后台。**\n\n请等待安装完成的管理员通知。")
|
||||
}
|
||||
|
||||
_ = ctx.Bot().AnswerCallbackQuery(ctx, tu.CallbackQuery(cq.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 默认回答,避免用户界面卡住
|
||||
_ = ctx.Bot().AnswerCallbackQuery(ctx, tu.CallbackQuery(cq.ID).WithText("操作已完成。"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 新增一个公共方法 (大写 G) 来包装私有方法
|
||||
func (t *Tgbot) GetDomain() (string, error) {
|
||||
return t.getDomain()
|
||||
}
|
||||
|
||||
// openPortWithUFW 检查/安装 ufw 并放行指定的端口
|
||||
func (t *Tgbot) openPortWithUFW(port int) error {
|
||||
// 将 Shell 逻辑整合为一个可执行的命令,并使用 /bin/bash -c 执行
|
||||
shellCommand := fmt.Sprintf(`
|
||||
PORT_TO_OPEN=%d
|
||||
|
||||
echo "正在为入站配置自动检查并放行端口 $PORT_TO_OPEN"
|
||||
|
||||
# 1. 检查/安装 ufw
|
||||
if ! command -v ufw &>/dev/null; then
|
||||
echo "ufw 防火墙未安装,正在安装..."
|
||||
# 使用绝对路径执行 apt-get,避免 PATH 问题
|
||||
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get update -qq >/dev/null
|
||||
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get install -y ufw
|
||||
if [ $? -ne 0 ]; then echo "❌ ufw 安装失败。"; exit 1; fi
|
||||
fi
|
||||
|
||||
# 2. 放行端口
|
||||
echo "正在执行 ufw allow $PORT_TO_OPEN..."
|
||||
ufw allow $PORT_TO_OPEN
|
||||
if [ $? -ne 0 ]; then echo "❌ ufw 端口放行失败。"; exit 1; fi
|
||||
|
||||
# 3. 检查/激活防火墙
|
||||
if ! ufw status | grep -q "Status: active"; then
|
||||
echo "ufw 状态:未激活。正在尝试激活..."
|
||||
ufw --force enable
|
||||
if [ $? -ne 0 ]; then echo "❌ ufw 激活失败。"; exit 1; fi
|
||||
fi
|
||||
echo "✅ 端口 $PORT_TO_OPEN 已成功放行/检查。"
|
||||
`, port) // 仅需传递一次 port 参数给 Shell 变量定义
|
||||
|
||||
// 使用 exec.CommandContext 运行命令
|
||||
cmd := exec.CommandContext(context.Background(), "/bin/bash", "-c", shellCommand)
|
||||
|
||||
// 捕获命令的输出
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// 记录日志,以便诊断
|
||||
logOutput := string(output)
|
||||
logger.Infof("执行 ufw 端口放行命令(端口 %d)结果:\n%s", port, logOutput)
|
||||
|
||||
if err != nil {
|
||||
// 返回详细的错误信息,包括 Shell 脚本的输出
|
||||
return fmt.Errorf("执行 ufw 端口放行脚本失败: %v, Shell 输出: %s", err, logOutput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user