From dc2f077a918a38e8db3af2df424811d7fc9c3d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=83=E9=9A=A8=E7=B7=A3=E5=8B=95?= Date: Wed, 8 Oct 2025 00:04:10 +0800 Subject: [PATCH] New features: TG Telegram robot terminal [One-click configuration] and [Subscription conversion] --- web/service/tgbot.go | 844 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 836 insertions(+), 8 deletions(-) diff --git a/web/service/tgbot.go b/web/service/tgbot.go index 0292e888..d87a86a9 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -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 +}