diff --git a/backend/internal/telegram/bot_commands.go b/backend/internal/telegram/bot_commands.go index c019f07..6f91195 100644 --- a/backend/internal/telegram/bot_commands.go +++ b/backend/internal/telegram/bot_commands.go @@ -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:%s", 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 "用法:/bind <用户名> <Telegram ID>", 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 "🤖 3m-ui\n\n/start、/help:打开菜单\n/status:查看状态\n/id:查看 Telegram ID\n/usage <关键词>:查询客户端\n/inbound <remark>:查看入站\n/restart:重启 Mihomo\n/bind <用户名> <Telegram ID>:绑定客户端" } - return "🤖 3m-ui\n\n/status:查看服务状态\n/id:查看 Telegram ID\n/usage:查看自己的用量\n使用下面的按钮即可查看个人信息。" + s, _ := LoadSettings(b.db) + if b.isAdmin(chatID, s) { + return "🤖 3m-ui\n\n/start、/help:打开菜单\n/status:查看服务状态\n/id:查看 Telegram ID\n/usage <关键词>:查询客户端\n/inbound <remark>:查看入站\n/restart:重启 Mihomo\n/bind <用户名> <Telegram ID>:绑定客户端" + } + return "🤖 3m-ui\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("📊 服务状态\nMihomo:%s\n版本:%s\nPID:%d\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("📡 %s\n协议:%s\n地址:%s\n端口:%s\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("✅ 已将 %s 绑定到 Telegram ID %d。", 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("👥 客户端\n"); for _,u:=range users { out.WriteString(fmt.Sprintf("%s %s %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("👥 客户端\n");for _,u:=range users{flag:="⛔";if user.IsCredentialActive(u){flag="🟢"};out.WriteString(fmt.Sprintf("%s %s %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("🟢 在线客户端\n");for _,u:=range users{out.WriteString("• "+escapeHTML(u.Username)+"\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("🟢 在线客户端\n");for _,u:=range users{out.WriteString("• "+escapeHTML(u.Username)+"\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("📡 入站\n");for _,n:=range list{state:="🔴";if n.Enabled{state="🟢"};out.WriteString(fmt.Sprintf("%s %s %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("📈 流量排行\n");for i,u:=range users{out.WriteString(fmt.Sprintf("%d. %s %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("✅ 客户端已创建\n用户名:%s\n密码:%s\nUUID:%s\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(""+escapeHTML(x)+"\n")}}};if out.Len()==0{return "当前没有可用链接。"};return "🔗 我的链接\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("✅ 客户端已创建\n用户名:%s\n密码:%s\nUUID:%s",escapeHTML(u.Username),escapeHTML(password),escapeHTML(u.UUID));if len(links)>0{msg+="\n\n🔗 链接\n"+escapeHTML(strings.Join(links,"\n"))+""};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(""+escapeHTML(x)+"\n")}}};if out.Len()==0{return "当前没有可用链接。"};return "🔗 我的链接\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("• "+escapeHTML(u.Username)+" — "+reasonText(blockReason(u))+"\n")}};if out.Len()==0{return "当前没有被阻止的客户端。"};return "🚫 封禁状态\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("⚠️ %s %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=1024&&i