New features: TG Telegram robot terminal [One-click configuration] and [Subscription conversion]

This commit is contained in:
心隨緣動 2025-10-08 00:04:10 +08:00 committed by GitHub
parent e18ce0f23f
commit dc2f077a91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -15,6 +15,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"encoding/json" // 新增:用于 json.Marshal / Unmarshal
"net/http" // 新增:用于 http.Client / Transport
"crypto/tls" // 新增:用于 tls.Config
"os/exec" // 新增:用于 exec.CommandgetDomain 等)
"path/filepath" // 新增:用于 filepath.Base / DirgetDomain 用到)
"x-ui/config" "x-ui/config"
"x-ui/database" "x-ui/database"
@ -31,12 +36,23 @@ import (
tu "github.com/mymmrac/telego/telegoutil" tu "github.com/mymmrac/telego/telegoutil"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy" "github.com/valyala/fasthttp/fasthttpproxy"
// 新增 qrcode 包,用于生成二维码
"github.com/skip2/go-qrcode"
) )
// 〔中文注释〕: 新增 TelegramService 接口,用于解耦 Job 和 Telegram Bot 的直接依赖。 // 〔中文注释〕: 新增 TelegramService 接口,用于解耦 Job 和 Telegram Bot 的直接依赖。
// 任何实现了 SendMessage(msg string) error 方法的结构体,都可以被认为是 TelegramService。 // 任何实现了 SendMessage(msg string) error 方法的结构体,都可以被认为是 TelegramService。
type TelegramService interface { 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 ( var (
@ -77,16 +93,47 @@ const (
) )
type Tgbot struct { type Tgbot struct {
inboundService InboundService inboundService *InboundService
settingService SettingService settingService *SettingService
serverService ServerService serverService *ServerService
xrayService XrayService xrayService *XrayService
lastStatus *Status 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 { func (t *Tgbot) NewTgbot() *Tgbot {
return new(Tgbot) return new(Tgbot)
} }
*/
func (t *Tgbot) I18nBot(name string, params ...string) string { func (t *Tgbot) I18nBot(name string, params ...string) string {
return locale.I18n(locale.Bot, name, params...) 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 { func (t *Tgbot) Start(i18nFS embed.FS) error {
// Initialize localizer // Initialize localizer
err := locale.InitLocalizer(i18nFS, &t.settingService) err := locale.InitLocalizer(i18nFS, t.settingService)
if err != nil { if err != nil {
return err return err
} }
@ -160,6 +207,8 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")}, {Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")}, {Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")}, {Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
{Command: "oneclick", Description: "🚀 一键配置节点 (有可选项)"},
{Command: "subconverter", Description: "🔄 检测或安装订阅转换"},
}, },
}) })
if err != nil { if err != nil {
@ -499,6 +548,23 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
} else { } else {
handleUnknownCommand() 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: default:
handleUnknownCommand() handleUnknownCommand()
} }
@ -1637,7 +1703,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove())
return return
} }
for _, valid_emails := range valid_emails { for _, valid_emails := range valid_emails {
traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails) traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails)
if err != nil { if err != nil {
@ -1660,6 +1726,43 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove()) 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.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")), 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. // TODOOOOOOOOOOOOOO: Add restart button here.
) )
numericKeyboardClient := tu.InlineKeyboard( numericKeyboardClient := tu.InlineKeyboard(
@ -2993,3 +3101,723 @@ func (t *Tgbot) SendMessage(msg string) error {
t.SendMsgToTgbotAdmins(msg) t.SendMsgToTgbotAdmins(msg)
return nil 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", // 前端硬编码选择项
})
// streamSettingsnetwork=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
}