mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 08:16:56 +08:00
feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and ⏳ placeholder
- Account state injection at conversation start (models, exchanges,
strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
This commit is contained in:
@@ -137,6 +137,19 @@ func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// GetAnyEnabled returns the first enabled AI model across all users.
|
||||
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
|
||||
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("enabled = ? AND api_key != ''", true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// Update updates AI model, creates if not exists
|
||||
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
||||
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
|
||||
@@ -18,17 +18,18 @@ type Store struct {
|
||||
driver *DBDriver // Database driver for abstraction (legacy)
|
||||
|
||||
// Sub-stores (lazy initialization)
|
||||
user *UserStore
|
||||
aiModel *AIModelStore
|
||||
exchange *ExchangeStore
|
||||
trader *TraderStore
|
||||
decision *DecisionStore
|
||||
backtest *BacktestStore
|
||||
position *PositionStore
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
user *UserStore
|
||||
aiModel *AIModelStore
|
||||
exchange *ExchangeStore
|
||||
trader *TraderStore
|
||||
decision *DecisionStore
|
||||
backtest *BacktestStore
|
||||
position *PositionStore
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
telegramConfig TelegramConfigStore
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -160,6 +161,9 @@ func (s *Store) initTables() error {
|
||||
if err := s.Grid().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize grid tables: %w", err)
|
||||
}
|
||||
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize telegram config tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -293,6 +297,16 @@ func (s *Store) Grid() *GridStore {
|
||||
return s.grid
|
||||
}
|
||||
|
||||
// TelegramConfig gets Telegram bot configuration storage
|
||||
func (s *Store) TelegramConfig() TelegramConfigStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.telegramConfig == nil {
|
||||
s.telegramConfig = NewTelegramConfigStore(s.gdb)
|
||||
}
|
||||
return s.telegramConfig
|
||||
}
|
||||
|
||||
// Close closes database connection
|
||||
func (s *Store) Close() error {
|
||||
if s.driver != nil {
|
||||
|
||||
135
store/telegram_config.go
Normal file
135
store/telegram_config.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelegramConfig stores the Telegram bot binding (single row, always ID=1)
|
||||
type TelegramConfig struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
BotToken string `gorm:"column:bot_token"`
|
||||
ChatID int64 `gorm:"column:chat_id"`
|
||||
Username string `gorm:"column:username"` // @username for display
|
||||
BoundAt time.Time `gorm:"column:bound_at"`
|
||||
ModelID string `gorm:"column:model_id;default:''"` // AI model used for Telegram replies
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// String returns a safe string representation of TelegramConfig with the token masked.
|
||||
func (tc TelegramConfig) String() string {
|
||||
token := "***"
|
||||
if tc.BotToken == "" {
|
||||
token = "<not set>"
|
||||
}
|
||||
return fmt.Sprintf("TelegramConfig{ID:%d, ChatID:%d, Username:%q, BotToken:%s, BoundAt:%v}",
|
||||
tc.ID, tc.ChatID, tc.Username, token, tc.BoundAt)
|
||||
}
|
||||
|
||||
// TelegramConfigStore defines the interface for Telegram bot binding operations
|
||||
type TelegramConfigStore interface {
|
||||
Get() (*TelegramConfig, error) // Get current config (may not exist)
|
||||
SaveToken(botToken string) error // Save bot token only (Web UI sets this)
|
||||
Save(botToken, modelID string) error // Save bot token + selected AI model
|
||||
BindUser(chatID int64, username string) error // Called on first /start
|
||||
IsBound() (bool, error) // Check if any user is bound
|
||||
GetBoundChatID() (int64, error) // Get bound chat ID (0 if not bound)
|
||||
Unbind() error // Remove binding
|
||||
}
|
||||
|
||||
type telegramConfigStore struct {
|
||||
db *gorm.DB
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTelegramConfigStore creates a new TelegramConfigStore
|
||||
func NewTelegramConfigStore(db *gorm.DB) TelegramConfigStore {
|
||||
return &telegramConfigStore{db: db}
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) initTables() error {
|
||||
return s.db.AutoMigrate(&TelegramConfig{})
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Get() (*TelegramConfig, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) SaveToken(botToken string) error {
|
||||
return s.Save(botToken, "")
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Save(botToken, modelID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.BotToken = botToken
|
||||
cfg.ModelID = modelID
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) BindUser(chatID int64, username string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.ChatID = chatID
|
||||
cfg.Username = username
|
||||
cfg.BoundAt = time.Now()
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) IsBound() (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return cfg.ChatID != 0, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) GetBoundChatID() (int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return cfg.ChatID, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Unbind() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.db.Model(&TelegramConfig{}).Where("id = 1").Updates(map[string]interface{}{
|
||||
"chat_id": 0,
|
||||
"username": "",
|
||||
}).Error
|
||||
}
|
||||
Reference in New Issue
Block a user