mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets, orders, RPC nodes, settings, dashboard stats, notifications - Add JWT-based admin authentication and API key auth middleware - Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket - Add RPC node health check job with automatic failover - Add Telegram notification channel for order events - Add installer for first-run database initialization - Add new DB models: admin_user, api_key, chain, chain_token, rpc_node, settings, notification_channel - Add order statistics aggregation (daily/monthly/by-chain) - Serve built admin SPA from embedded www/ assets - Add comprehensive test coverage for all new features
This commit is contained in:
+8
-12
@@ -122,7 +122,9 @@ func WalletList(c tb.Context) error {
|
||||
InlineKeyboard: rows,
|
||||
})
|
||||
})
|
||||
btnList = append(btnList, []tb.InlineButton{addBtn})
|
||||
refreshBtn := tb.InlineButton{Text: "刷新列表", Unique: "WalletRefresh"}
|
||||
bots.Handle(&refreshBtn, WalletList)
|
||||
btnList = append(btnList, []tb.InlineButton{addBtn, refreshBtn})
|
||||
|
||||
return c.EditOrSend("请选择钱包继续操作", &tb.ReplyMarkup{
|
||||
InlineKeyboard: btnList,
|
||||
@@ -257,21 +259,15 @@ func getPendingWalletAddressState(userID int64) (pendingWalletAddressState, bool
|
||||
}
|
||||
|
||||
func getEnabledSupportedNetworks() ([]string, error) {
|
||||
assets, err := data.ListEnabledSupportedAssets()
|
||||
chains, err := data.ListEnabledChains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := make(map[string]struct{})
|
||||
for _, a := range assets {
|
||||
n := strings.ToLower(strings.TrimSpace(a.Network))
|
||||
if n == "" {
|
||||
continue
|
||||
networks := make([]string, 0, len(chains))
|
||||
for _, ch := range chains {
|
||||
if n := strings.ToLower(strings.TrimSpace(ch.Network)); n != "" {
|
||||
networks = append(networks, n)
|
||||
}
|
||||
set[n] = struct{}{}
|
||||
}
|
||||
networks := make([]string, 0, len(set))
|
||||
for n := range set {
|
||||
networks = append(networks, n)
|
||||
}
|
||||
sort.Strings(networks)
|
||||
return networks, nil
|
||||
|
||||
+103
-49
@@ -1,66 +1,120 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"github.com/assimon/luuu/config"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
tb "gopkg.in/telebot.v3"
|
||||
"gopkg.in/telebot.v3/middleware"
|
||||
"time"
|
||||
)
|
||||
|
||||
var bots *tb.Bot
|
||||
// bots is the single bot instance backing slash-command handlers
|
||||
// (/start, WalletList, add-wallet dialog). Config is read from the
|
||||
// settings table (system.telegram_bot_token + system.telegram_chat_id).
|
||||
// Decoupled from notification_channels — that table drives push
|
||||
// notifications only (via notify/dispatcher.go).
|
||||
var (
|
||||
bots *tb.Bot
|
||||
adminChatID int64
|
||||
reloadMu sync.Mutex
|
||||
)
|
||||
|
||||
// BotStart 机器人启动
|
||||
// BotStart connects the command bot. If no telegram channel is
|
||||
// configured yet it logs and returns; the service otherwise runs fine
|
||||
// and notifications still fan out as soon as a channel is added (those
|
||||
// use their own per-request bot; see notify/telegram_sender.go).
|
||||
func BotStart() {
|
||||
var err error
|
||||
botSetting := tb.Settings{
|
||||
Token: config.TgBotToken,
|
||||
Poller: &tb.LongPoller{Timeout: 50 * time.Second},
|
||||
Synchronous: true,
|
||||
if err := reloadBot("bootstrap"); err != nil {
|
||||
log.Sugar.Errorf("[telegram] bot start failed: %v", err)
|
||||
}
|
||||
if config.TgProxy != "" {
|
||||
botSetting.URL = config.TgProxy
|
||||
}
|
||||
bots, err = tb.NewBot(botSetting)
|
||||
if err != nil {
|
||||
log.Sugar.Error(err.Error())
|
||||
return
|
||||
}
|
||||
err = bots.SetCommands(Cmds)
|
||||
if err != nil {
|
||||
log.Sugar.Error(err.Error())
|
||||
return
|
||||
}
|
||||
RegisterHandle()
|
||||
bots.Start()
|
||||
}
|
||||
|
||||
// RegisterHandle 注册处理器
|
||||
func RegisterHandle() {
|
||||
adminOnly := bots.Group()
|
||||
adminOnly.Use(middleware.Whitelist(config.TgManage))
|
||||
adminOnly.Handle(START_CMD, WalletList)
|
||||
adminOnly.Handle(tb.OnText, OnTextMessageHandle)
|
||||
}
|
||||
|
||||
// SendToBot 主动发送消息机器人消息
|
||||
func SendToBot(msg string) {
|
||||
// ReloadBotAsync refreshes the command bot from notification_channels.
|
||||
// It is used by admin API handlers after telegram channel create/update/
|
||||
// status/delete so operators don't need to restart the service.
|
||||
func ReloadBotAsync(reason string) {
|
||||
go func() {
|
||||
if bots == nil {
|
||||
log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息")
|
||||
return
|
||||
}
|
||||
user := tb.User{
|
||||
ID: config.TgManage,
|
||||
}
|
||||
log.Sugar.Infof("[Telegram] 正在发送消息给用户ID=%d", config.TgManage)
|
||||
_, err := bots.Send(&user, msg, &tb.SendOptions{
|
||||
ParseMode: tb.ModeHTML,
|
||||
})
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[Telegram] 发送消息失败: userID=%d, err=%v", config.TgManage, err)
|
||||
} else {
|
||||
log.Sugar.Infof("[Telegram] 发送消息成功: userID=%d", config.TgManage)
|
||||
if err := reloadBot(reason); err != nil {
|
||||
log.Sugar.Errorf("[telegram] reload failed, reason=%s err=%v", reason, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// loadCommandBotConfig reads the command-bot config from the settings
|
||||
// table (system.telegram_bot_token + system.telegram_chat_id).
|
||||
// Returns (nil, "", nil) when the keys are absent so the caller can
|
||||
// log and return gracefully — bot stays disabled until settings are set.
|
||||
func loadCommandBotConfig() (*botConfig, string, error) {
|
||||
botToken := strings.TrimSpace(data.GetSettingString("system.telegram_bot_token", ""))
|
||||
chatIDStr := strings.TrimSpace(data.GetSettingString("system.telegram_chat_id", ""))
|
||||
if botToken == "" || chatIDStr == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid telegram chat_id in settings: %w", err)
|
||||
}
|
||||
return &botConfig{BotToken: botToken, ChatID: chatID}, "settings", nil
|
||||
}
|
||||
|
||||
// botConfig holds the minimal fields needed to start the command bot.
|
||||
type botConfig struct {
|
||||
BotToken string
|
||||
ChatID int64
|
||||
}
|
||||
|
||||
func reloadBot(reason string) error {
|
||||
reloadMu.Lock()
|
||||
defer reloadMu.Unlock()
|
||||
|
||||
if bots != nil {
|
||||
bots.Stop()
|
||||
bots = nil
|
||||
}
|
||||
|
||||
cfg, source, err := loadCommandBotConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg == nil {
|
||||
log.Sugar.Infof("[telegram] no enabled telegram channel configured; command bot disabled (reason=%s)", reason)
|
||||
return nil
|
||||
}
|
||||
adminChatID = cfg.ChatID
|
||||
|
||||
botSetting := tb.Settings{
|
||||
Token: cfg.BotToken,
|
||||
Poller: &tb.LongPoller{Timeout: 50 * time.Second},
|
||||
Synchronous: true,
|
||||
}
|
||||
newBot, err := tb.NewBot(botSetting)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new bot: %w", err)
|
||||
}
|
||||
if err = newBot.SetCommands(Cmds); err != nil {
|
||||
return fmt.Errorf("set commands: %w", err)
|
||||
}
|
||||
bots = newBot
|
||||
RegisterHandle()
|
||||
go bots.Start()
|
||||
|
||||
log.Sugar.Infof("[telegram] command bot started, reason=%s, source=%s", reason, source)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterHandle wires slash-command and text-message routes. The
|
||||
// admin whitelist uses the chat_id from the same channel row.
|
||||
func RegisterHandle() {
|
||||
if bots == nil {
|
||||
return
|
||||
}
|
||||
adminOnly := bots.Group()
|
||||
adminOnly.Use(middleware.Whitelist(adminChatID))
|
||||
adminOnly.Handle(START_CMD, WalletList)
|
||||
adminOnly.Handle(tb.OnText, OnTextMessageHandle)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/internal/testutil"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
)
|
||||
|
||||
func withTelegramEnv(token, proxy string, manage int64, fn func()) {
|
||||
origToken := config.TgBotToken
|
||||
origProxy := config.TgProxy
|
||||
origManage := config.TgManage
|
||||
defer func() {
|
||||
config.TgBotToken = origToken
|
||||
config.TgProxy = origProxy
|
||||
config.TgManage = origManage
|
||||
}()
|
||||
|
||||
config.TgBotToken = token
|
||||
config.TgProxy = proxy
|
||||
config.TgManage = manage
|
||||
fn()
|
||||
}
|
||||
|
||||
func TestLoadCommandBotConfig_DoesNotUseEnvFallbackWhenNoChannel(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// Env config is present but settings table is empty —
|
||||
// the bot must NOT fall back to config.TgBotToken.
|
||||
withTelegramEnv("env-token", "http://127.0.0.1:7890", 123456789, func() {
|
||||
cfg, source, err := loadCommandBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("expected nil config when settings table has no telegram keys, got %+v", cfg)
|
||||
}
|
||||
if source != "" {
|
||||
t.Fatalf("source = %q, want empty", source)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadCommandBotConfig_PrefersEnabledTelegramChannel(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed settings table with telegram credentials.
|
||||
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_bot_token", Value: "db-token", Type: "string"})
|
||||
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_chat_id", Value: "987654321", Type: "string"})
|
||||
_ = data.ReloadSettings()
|
||||
|
||||
withTelegramEnv("env-token", "", 123456789, func() {
|
||||
cfg, source, err := loadCommandBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if source != "settings" {
|
||||
t.Fatalf("source = %q, want %q", source, "settings")
|
||||
}
|
||||
if cfg.BotToken != "db-token" {
|
||||
t.Fatalf("bot token = %q, want %q", cfg.BotToken, "db-token")
|
||||
}
|
||||
if cfg.ChatID != 987654321 {
|
||||
t.Fatalf("chat id = %d, want %d", cfg.ChatID, int64(987654321))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadCommandBotConfig_ReturnsNilWhenNoChannelAndNoEnv(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// Fresh DB — ensure the cache doesn't carry over values from a previous test.
|
||||
_ = data.ReloadSettings()
|
||||
|
||||
withTelegramEnv("", "", 0, func() {
|
||||
cfg, source, err := loadCommandBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("expected nil config, got %+v", cfg)
|
||||
}
|
||||
if source != "" {
|
||||
t.Fatalf("source = %q, want empty", source)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadCommandBotConfig_SkipsEmptyConfigChannel(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// Only chat_id present, no bot_token → should return nil.
|
||||
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_chat_id", Value: "987654321", Type: "string"})
|
||||
_ = data.ReloadSettings()
|
||||
|
||||
cfg, source, err := loadCommandBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadCommandBotConfig err: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("expected nil config when bot_token is absent, got %+v", cfg)
|
||||
}
|
||||
if source != "" {
|
||||
t.Fatalf("source = %q, want empty", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCommandBotConfig_SkipsInvalidAndUsesNextValid(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// bot_token present but chat_id is not a valid int → should return error.
|
||||
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_bot_token", Value: "db-token", Type: "string"})
|
||||
dao.Mdb.Create(&mdb.Setting{Group: "system", Key: "system.telegram_chat_id", Value: "not-a-number", Type: "string"})
|
||||
_ = data.ReloadSettings()
|
||||
|
||||
cfg, _, err := loadCommandBotConfig()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid chat_id, got nil")
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("expected nil config on parse error, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user