feat(telegram): add commands, menus and client wizard

This commit is contained in:
風雪 committed 2026-08-17 09:49:01 +08:00
1 parent 3ba89b6c38
commit 4e02b541ca
1 file changed
+49 -52
+49 -52
View File
@@ -7,7 +7,6 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/kazeyukiro/3m-ui/backend/internal/config"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
@@ -16,8 +15,9 @@ import (
)
func (b *Bot) isAdmin(chatID string, s Settings) bool {
chatID = strings.TrimSpace(chatID)
for _, id := range s.ChatIDs { if strings.TrimSpace(id) == chatID { return true } }
for _, id := range s.ChatIDs {
if strings.TrimSpace(id) == strings.TrimSpace(chatID) { return true }
}
return false
}
@@ -29,29 +29,28 @@ func (b *Bot) linkedUser(chatID string) (*models.ProxyUser, error) {
}
func menu(admin bool) *InlineKeyboardMarkup {
rows := [][]InlineButton{{{Text:"📊 我的用量", CallbackData:"user:usage"},{Text:"🔗 我的链接", CallbackData:"user:links"}}}
if admin {
rows = [][]InlineButton{
{{Text:"📊 服务器用量", CallbackData:"admin:status"},{Text:"📈 流量排行", CallbackData:"admin:traffic"}},
{{Text:"👥 客户端", CallbackData:"admin:users"},{Text:"🟢 在线客户端", CallbackData:"admin:online"}},
{{Text:"📡 入站", CallbackData:"admin:inbounds"},{Text:"⚠️ 即将耗尽", CallbackData:"admin:low"}},
{{Text:"🔄 重置流量", CallbackData:"admin:reset"},{Text:"➕ 添加客户端", CallbackData:"admin:add"}},
{{Text:"💾 数据库备份", CallbackData:"admin:backup"},{Text:"🚫 封禁状态", CallbackData:"admin:blocked"}},
}
} else {
rows = append(rows, []InlineButton{{Text:"📱 我的 ID", CallbackData:"user:id"}})
if !admin {
return &InlineKeyboardMarkup{InlineKeyboard: [][]InlineButton{
{{Text:"📊 我的用量", CallbackData:"user:usage"},{Text:"🔗 我的链接", CallbackData:"user:links"}},
{{Text:"📱 我的 ID", CallbackData:"user:id"}},
}}
}
return &InlineKeyboardMarkup{InlineKeyboard: rows}
return &InlineKeyboardMarkup{InlineKeyboard: [][]InlineButton{
{{Text:"📊 服务器用量", CallbackData:"admin:status"},{Text:"📈 流量排行", CallbackData:"admin:traffic"}},
{{Text:"👥 客户端", CallbackData:"admin:users"},{Text:"🟢 在线客户端", CallbackData:"admin:online"}},
{{Text:"📡 入站", CallbackData:"admin:inbounds"},{Text:"⚠️ 即将耗尽", CallbackData:"admin:low"}},
{{Text:"➕ 添加客户端", CallbackData:"admin:add"},{Text:"🚫 封禁状态", CallbackData:"admin:blocked"}},
}}
}
func (b *Bot) handleCommand(chatID, text string) (string, *InlineKeyboardMarkup) {
parts := strings.Fields(strings.TrimSpace(text))
if len(parts) == 0 { return b.help(chatID), menu(b.isAdmin(chatID, mustSettings(b.db))) }
s, _ := LoadSettings(b.db)
admin := b.isAdmin(chatID, s)
if len(parts) == 0 { return b.help(chatID), menu(admin) }
cmd := strings.ToLower(parts[0]); if i := strings.IndexByte(cmd, '@'); i >= 0 { cmd = cmd[:i] }
s := mustSettings(b.db); admin := b.isAdmin(chatID, s)
switch cmd {
case "/start": return b.help(chatID), menu(admin)
case "/help": return b.help(chatID), menu(admin)
case "/start", "/help": return b.help(chatID), menu(admin)
case "/status": return b.cmdStatus(), nil
case "/id": return fmt.Sprintf("你的 Telegram ID:<code>%s</code>", escapeHTML(chatID)), nil
case "/usage":
@@ -66,32 +65,27 @@ func (b *Bot) handleCommand(chatID, text string) (string, *InlineKeyboardMarkup)
if b.mihomo == nil { return "Mihomo 服务未初始化。", nil }
if err := b.mihomo.RestartMihomo(); err != nil { return "重启失败:" + escapeHTML(err.Error()), nil }
return "✅ Mihomo 已重启。", nil
case "/users", "/clients":
if !admin { return "这个命令只对管理员开放。", nil }; return b.cmdUsers(), nil
case "/online":
if !admin { return b.usageForChat(chatID), nil }; return b.cmdOnline(), nil
case "/listeners", "/nodes":
if !admin { return "这个命令只对管理员开放。", nil }; return b.cmdListeners(), nil
case "/traffic":
if !admin { return "这个命令只对管理员开放。", nil }; return b.cmdTraffic(), nil
case "/bind":
if !admin || len(parts) < 3 { return "用法:<code>/bind &lt;用户名&gt; &lt;Telegram ID&gt;</code>", nil }
return b.bindUser(parts[1], parts[2]), nil
case "/users", "/clients": if !admin { return "这个命令只对管理员开放。", nil }; return b.cmdUsers(), nil
case "/online": if !admin { return b.usageForChat(chatID), nil }; return b.cmdOnline(), nil
case "/listeners", "/nodes": if !admin { return "这个命令只对管理员开放。", nil }; return b.cmdListeners(), nil
case "/traffic": if !admin { return "这个命令只对管理员开放。", nil }; return b.cmdTraffic(), nil
default: return "未知指令。发送 /help 查看菜单。", menu(admin)
}
}
func mustSettings(db interface{ }) Settings { s, _ := LoadSettings(db.(*gormDBCompat).db); return s }
type gormDBCompat struct { db *gorm.DB }
func (b *Bot) help(chatID string) string {
s, _ := LoadSettings(b.db); admin := b.isAdmin(chatID, s)
if admin { return "🤖 <b>3m-ui</b>\n\n/start、/help:打开菜单\n/status:查看状态\n/id:查看 Telegram ID\n/usage &lt;关键词&gt;:查询客户端\n/inbound &lt;remark&gt;:查看入站\n/restart:重启 Mihomo\n/bind &lt;用户名&gt; &lt;Telegram ID&gt;:绑定客户端" }
return "🤖 <b>3m-ui</b>\n\n/status:查看服务状态\n/id:查看 Telegram ID\n/usage:查看自己的用量\n使用下面的按钮即可查看个人信息。"
s, _ := LoadSettings(b.db)
if b.isAdmin(chatID, s) {
return "🤖 <b>3m-ui</b>\n\n/start、/help:打开菜单\n/status:查看服务状态\n/id:查看 Telegram ID\n/usage &lt;关键词&gt;:查询客户端\n/inbound &lt;remark&gt;:查看入站\n/restart:重启 Mihomo\n/bind &lt;用户名&gt; &lt;Telegram ID&gt;:绑定客户端"
}
return "🤖 <b>3m-ui</b>\n\n/status:查看服务状态\n/id:查看 Telegram ID\n/usage:查看自己的用量\n使用下面的按钮查看个人信息。"
}
func (b *Bot) cmdStatus() string {
running, version := false, "-"; pid := 0
running, version, pid := false, "-", 0
if b.mihomo != nil { if st, err := b.mihomo.GetStatus(); err == nil && st != nil { running, version, pid = st.Running, st.Version, st.PID } }
var users, listeners, online int64
b.db.Model(&models.ProxyUser{}).Count(&users); b.db.Model(&models.Listener{}).Count(&listeners); b.db.Model(&models.ProxyUser{}).Where("online = ?", true).Count(&online)
@@ -99,14 +93,10 @@ func (b *Bot) cmdStatus() string {
return fmt.Sprintf("📊 <b>服务状态</b>\nMihomo:%s\n版本:<code>%s</code>\nPID:<code>%d</code>\n客户端:%d(在线 %d)\n入站:%d", state, escapeHTML(version), pid, users, online, listeners)
}
func (b *Bot) usageForChat(chatID string) string {
u, err := b.linkedUser(chatID); if err != nil { return "还没有绑定 3m-ui 客户端。请把你的 Telegram ID 发给管理员绑定。" }
return formatUserUsage(*u)
}
func (b *Bot) usageForChat(chatID string) string { u, err := b.linkedUser(chatID); if err != nil { return "还没有绑定 3m-ui 客户端。请把你的 Telegram ID 发给管理员绑定。" }; return formatUserUsage(*u) }
func (b *Bot) usageSearch(q string) string {
var users []models.ProxyUser
like := "%" + q + "%"
var users []models.ProxyUser; like := "%" + q + "%"
if err := b.db.Where("username LIKE ? OR CAST(id AS TEXT) = ? OR CAST(telegram_id AS TEXT) = ?", like, q, q).Limit(10).Find(&users).Error; err != nil { return "查询失败:" + escapeHTML(err.Error()) }
if len(users) == 0 { return "没有找到客户端。" }
var out strings.Builder; for _, u := range users { out.WriteString(formatUserUsage(u)); out.WriteString("\n") }; return strings.TrimSpace(out.String())
@@ -120,7 +110,8 @@ func formatUserUsage(u models.ProxyUser) string {
}
func (b *Bot) inbound(name string) string {
var n models.Listener; if err := b.db.Where("name = ?", name).First(&n).Error; err != nil { if err := b.db.Where("name LIKE ?", "%"+name+"%").First(&n).Error; err != nil { return "没有找到这个入站。" } }
var n models.Listener
if err := b.db.Where("name = ?", name).First(&n).Error; err != nil { if err := b.db.Where("name LIKE ?", "%"+name+"%").First(&n).Error; err != nil { return "没有找到这个入站。" } }
return fmt.Sprintf("📡 <b>%s</b>\n协议:<code>%s</code>\n地址:<code>%s</code>\n端口:<code>%s</code>\nTLS:%t\nUDP:%t\n状态:%t", escapeHTML(n.Name), escapeHTML(n.Protocol), escapeHTML(n.BindAddress), escapeHTML(n.Port), n.TLS, n.UDP, n.Enabled)
}
@@ -131,9 +122,9 @@ func (b *Bot) bindUser(username, chatID string) string {
return fmt.Sprintf("✅ 已将 <code>%s</code> 绑定到 Telegram ID <code>%d</code>。", escapeHTML(username), id)
}
func (b *Bot) cmdUsers() string { var users []models.ProxyUser; if err:=b.db.Order("id asc").Limit(40).Find(&users).Error; err!=nil{return "读取客户端失败。"}; if len(users)==0{return "暂无客户端。"}; var out strings.Builder; out.WriteString("👥 <b>客户端</b>\n"); for _,u:=range users { out.WriteString(fmt.Sprintf("%s <code>%s</code> %s/%s\n", map[bool]string{true:"🟢",false:"⛔"}[user.IsCredentialActive(u)],escapeHTML(u.Username),formatBytes(u.TrafficUsed),quota(u))) }; return out.String() }
func (b *Bot) cmdUsers() string { var users []models.ProxyUser; if err:=b.db.Order("id asc").Limit(40).Find(&users).Error; err!=nil{return "读取客户端失败。"};if len(users)==0{return "暂无客户端。"};var out strings.Builder;out.WriteString("👥 <b>客户端</b>\n");for _,u:=range users{flag:="⛔";if user.IsCredentialActive(u){flag="🟢"};out.WriteString(fmt.Sprintf("%s <code>%s</code> %s/%s\n",flag,escapeHTML(u.Username),formatBytes(u.TrafficUsed),quota(u)))};return out.String() }
func quota(u models.ProxyUser) string { if u.TrafficLimit<=0{return "∞"};return formatBytes(u.TrafficLimit) }
func (b *Bot) cmdOnline() string { var users []models.ProxyUser; b.db.Where("online = ?",true).Order("id asc").Limit(40).Find(&users); if len(users)==0{return "当前没有在线客户端。"}; var out strings.Builder; out.WriteString("🟢 <b>在线客户端</b>\n");for _,u:=range users{out.WriteString("• <code>"+escapeHTML(u.Username)+"</code>\n")};return out.String() }
func (b *Bot) cmdOnline() string { var users []models.ProxyUser;b.db.Where("online = ?",true).Order("id asc").Limit(40).Find(&users);if len(users)==0{return "当前没有在线客户端。"};var out strings.Builder;out.WriteString("🟢 <b>在线客户端</b>\n");for _,u:=range users{out.WriteString("• <code>"+escapeHTML(u.Username)+"</code>\n")};return out.String() }
func (b *Bot) cmdListeners() string { var list []models.Listener;b.db.Order("id asc").Limit(40).Find(&list);if len(list)==0{return "暂无入站。"};var out strings.Builder;out.WriteString("📡 <b>入站</b>\n");for _,n:=range list{state:="🔴";if n.Enabled{state="🟢"};out.WriteString(fmt.Sprintf("%s <code>%s</code> %s:%s\n",state,escapeHTML(n.Name),escapeHTML(n.BindAddress),escapeHTML(n.Port)))};return out.String() }
func (b *Bot) cmdTraffic() string { var users []models.ProxyUser;b.db.Order("traffic_used desc").Limit(15).Find(&users);var out strings.Builder;out.WriteString("📈 <b>流量排行</b>\n");for i,u:=range users{out.WriteString(fmt.Sprintf("%d. <code>%s</code> %s\n",i+1,escapeHTML(u.Username),formatBytes(u.TrafficUsed)))};if len(users)==0{out.WriteString("暂无数据。")};return out.String() }
@@ -151,10 +142,9 @@ func (b *Bot) handleCallback(c *Client, s Settings, chatID, callbackID, data str
case "admin:inbounds": if !admin{return c.SendTo(chatID,"无权限。",nil)};return c.SendTo(chatID,b.cmdListeners(),nil)
case "admin:blocked": if !admin{return c.SendTo(chatID,"无权限。",nil)};return c.SendTo(chatID,b.blockedUsers(),nil)
case "admin:low": if !admin{return c.SendTo(chatID,"无权限。",nil)};return c.SendTo(chatID,b.lowUsers(),nil)
case "admin:reset": if !admin{return c.SendTo(chatID,"无权限。",nil)};return c.SendTo(chatID,"请在面板中选择具体客户端后重置流量,避免误清空全部用户。",nil)
case "admin:add": if !admin{return c.SendTo(chatID,"无权限。",nil)};return c.SendTo(chatID,"选择要绑定新客户端的入站:",b.inboundKeyboard())
default:
if strings.HasPrefix(data,"add:listener:") && admin { id,_:=strconv.ParseUint(strings.TrimPrefix(data,"add:listener:"),10,32);b.wizardMu.Lock();b.wizards[chatID]=&addClientWizard{ListenerID:uint(id)};b.wizardMu.Unlock();return c.SendTo(chatID,"请输入客户端用户名:",nil) }
if strings.HasPrefix(data,"add:listener:") && admin { id,err:=strconv.ParseUint(strings.TrimPrefix(data,"add:listener:"),10,32);if err!=nil{return err};b.wizardMu.Lock();b.wizards[chatID]=&addClientWizard{ListenerID:uint(id)};b.wizardMu.Unlock();return c.SendTo(chatID,"请输入客户端用户名:",nil) }
}
return nil
}
@@ -162,16 +152,23 @@ func (b *Bot) handleCallback(c *Client, s Settings, chatID, callbackID, data str
func (b *Bot) inboundKeyboard() *InlineKeyboardMarkup { var list []models.Listener;b.db.Where("enabled = ?",true).Order("id").Limit(30).Find(&list);rows:=make([][]InlineButton,0,len(list));for _,n:=range list{rows=append(rows,[]InlineButton{{Text:n.Name,CallbackData:"add:listener:"+strconv.FormatUint(uint64(n.ID),10)}})};return &InlineKeyboardMarkup{InlineKeyboard:rows} }
func (b *Bot) handleWizardMessage(chatID,text string) bool {
b.wizardMu.Lock(); w:=b.wizards[chatID]; b.wizardMu.Unlock();if w==nil{return false};name:=strings.TrimSpace(text);if strings.HasPrefix(name,"/"){return false};if len(name)<1||len(name)>100{return true}
b.wizardMu.Lock();w:=b.wizards[chatID];b.wizardMu.Unlock();if w==nil{return false};name:=strings.TrimSpace(text);if strings.HasPrefix(name,"/"){return false};if name==""||len(name)>100{return true}
passwordBytes:=make([]byte,12);if _,err:=rand.Read(passwordBytes);err!=nil{return true};password:=hex.EncodeToString(passwordBytes)
if b.users==nil{return true};u,err:=b.users.Create(user.CreateInput{Username:name,Password:password});if err!=nil{log.Printf("telegram: create client: %v",err);return true};if err:=b.users.BindListeners(u.ID,[]uint{w.ListenerID});err!=nil{log.Printf("telegram: bind client: %v",err);return true};b.wizardMu.Lock();delete(b.wizards,chatID);b.wizardMu.Unlock()
return b.sendWizardResult(chatID,u,name,password)
return b.sendWizardResult(chatID,u,password)
}
func (b *Bot) sendWizardResult(chatID string,u *models.ProxyUser,name,password string) bool { var n models.Listener;if err:=b.db.First(&n,u.ID).Error;err==nil{};var links string;var listeners []models.Listener;b.db.Model(&models.Listener{}).Joins("JOIN listener_users ON listener_users.listener_id = listeners.id AND listener_users.deleted_at IS NULL").Where("listener_users.proxy_user_id = ?",u.ID).Find(&listeners);host:="";if config.GlobalConfig!=nil{if x:=config.GlobalConfig.Server.PublicURL; x!=""{if p,e:=url.Parse(x);e==nil{host=p.Hostname()}}};creds:=[]user.Credential{{Username:name,Password:password,UUID:u.UUID}};for _,l:=range listeners{uris,e:=node.ClientURIsWithCredentials(l,host,creds);if e==nil{links+=strings.Join(uris,"\n")+"\n"}};msg:=fmt.Sprintf("✅ <b>客户端已创建</b>\n用户名:<code>%s</code>\n密码:<code>%s</code>\nUUID:<code>%s</code>\n%s",escapeHTML(name),escapeHTML(password),escapeHTML(u.UUID),escapeHTML(strings.TrimSpace(links)));return b.sendDirect(chatID,msg)}
func (b *Bot) sendDirect(chatID,text string) bool { c,s,e:=NewClientFromDB(b.db);if e!=nil||c==nil{return true};if e:=c.SendTo(chatID,text,nil);e!=nil{log.Printf("telegram: wizard reply: %v",e)};return true }
func (b *Bot) userLinks(chatID string) string {u,e:=b.linkedUser(chatID);if e!=nil{return "还没有绑定客户端。"};var ls []models.Listener;b.db.Model(&models.Listener{}).Joins("JOIN listener_users ON listener_users.listener_id=listeners.id AND listener_users.deleted_at IS NULL").Where("listener_users.proxy_user_id=?",u.ID).Find(&ls);host:="";if config.GlobalConfig!=nil{if x:=config.GlobalConfig.Server.PublicURL;x!=""{if p,e:=url.Parse(x);e==nil{host=p.Hostname()}}};by,err:=b.users.ActiveCredentialsByListener();if err!=nil{return "读取链接失败。"};var out strings.Builder;for _,l:=range ls{uris,err:=node.ClientURIsWithCredentials(l,host,by[l.ID]);if err==nil{for _,x:=range uris{out.WriteString("<code>"+escapeHTML(x)+"</code>\n")}}};if out.Len()==0{return "当前没有可用链接。"};return "🔗 <b>我的链接</b>\n"+out.String()}
func (b *Bot) sendWizardResult(chatID string,u *models.ProxyUser,password string) bool {
var listeners []models.Listener;b.db.Model(&models.Listener{}).Joins("JOIN listener_users ON listener_users.listener_id = listeners.id AND listener_users.deleted_at IS NULL").Where("listener_users.proxy_user_id = ?",u.ID).Find(&listeners)
host:="";if config.GlobalConfig!=nil&&config.GlobalConfig.Server.PublicURL!=""{if p,e:=url.Parse(config.GlobalConfig.Server.PublicURL);e==nil{host=p.Hostname()}}
cred:=[]user.Credential{{Username:u.Username,Password:password,UUID:u.UUID}};var links []string
for _,l:=range listeners{if uris,e:=node.ClientURIsWithCredentials(l,host,cred);e==nil{links=append(links,uris...)}}
msg:=fmt.Sprintf("✅ <b>客户端已创建</b>\n用户名:<code>%s</code>\n密码:<code>%s</code>\nUUID:<code>%s</code>",escapeHTML(u.Username),escapeHTML(password),escapeHTML(u.UUID));if len(links)>0{msg+="\n\n🔗 <b>链接</b>\n<code>"+escapeHTML(strings.Join(links,"\n"))+"</code>"};return b.sendDirect(chatID,msg)
}
func (b *Bot) sendDirect(chatID,text string) bool { c,_,err:=NewClientFromDB(b.db);if err!=nil||c==nil{return true};if err:=c.SendTo(chatID,text,nil);err!=nil{log.Printf("telegram: wizard reply: %v",err)};return true }
func (b *Bot) userLinks(chatID string) string {u,e:=b.linkedUser(chatID);if e!=nil{return "还没有绑定客户端。"};var ls []models.Listener;b.db.Model(&models.Listener{}).Joins("JOIN listener_users ON listener_users.listener_id = listeners.id AND listener_users.deleted_at IS NULL").Where("listener_users.proxy_user_id = ?",u.ID).Find(&ls);host:="";if config.GlobalConfig!=nil&&config.GlobalConfig.Server.PublicURL!=""{if p,e:=url.Parse(config.GlobalConfig.Server.PublicURL);e==nil{host=p.Hostname()}};by,err:=b.users.ActiveCredentialsByListener();if err!=nil{return "读取链接失败。"};var out strings.Builder;for _,l:=range ls{if uris,err:=node.ClientURIsWithCredentials(l,host,by[l.ID]);err==nil{for _,x:=range uris{out.WriteString("<code>"+escapeHTML(x)+"</code>\n")}}};if out.Len()==0{return "当前没有可用链接。"};return "🔗 <b>我的链接</b>\n"+out.String()}
func (b *Bot) blockedUsers() string {var us []models.ProxyUser;b.db.Find(&us);var out strings.Builder;for _,u:=range us{if !user.IsCredentialActive(u){out.WriteString("• <code>"+escapeHTML(u.Username)+"</code> — "+reasonText(blockReason(u))+"\n")}};if out.Len()==0{return "当前没有被阻止的客户端。"};return "🚫 <b>封禁状态</b>\n"+out.String()}
func (b *Bot) lowUsers() string {var us []models.ProxyUser;b.db.Where("traffic_limit > 0 AND traffic_used * 100 >= traffic_limit * 80").Order("traffic_used DESC").Limit(20).Find(&us);if len(us)==0{return "没有达到 80% 用量的客户端。"};var out strings.Builder;for _,u:=range us{out.WriteString(fmt.Sprintf("⚠️ <code>%s</code> %s/%s\n",escapeHTML(u.Username),formatBytes(u.TrafficUsed),formatBytes(u.TrafficLimit)))};return out.String()}
func formatBytes(n int64) string { if n<1024{return fmt.Sprintf("%d B",n)};units:=[]string{"KB","MB","GB","TB"};v:=float64(n)/1024;i:=0;for v>=1024&&i<len(units)-1{v/=1024;i++};return fmt.Sprintf("%.2f %s",v,units[i]) }
func formatBytes(n int64) string {if n<1024{return fmt.Sprintf("%d B",n)};units:=[]string{"KB","MB","GB","TB"};v:=float64(n)/1024;i:=0;for v>=1024&&i<len(units)-1{v/=1024;i++};return fmt.Sprintf("%.2f %s",v,units[i])}