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:
tinkle-community
2026-03-08 00:19:38 +08:00
parent 73f1fe105d
commit 3168a18c0d
28 changed files with 4673 additions and 118 deletions

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ nofx_test
# Go 相关
*.test
*.out
.gocache/
# 操作系统
.DS_Store

66
api/route_registry.go Normal file
View File

@@ -0,0 +1,66 @@
package api
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
// RouteDoc holds documentation for a single API route.
type RouteDoc struct {
Method string
Path string
Description string
Schema string // optional: full parameter/body schema documentation
}
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
var routeRegistry []RouteDoc
// route registers an HTTP route with a one-line description.
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
s.routeWithSchema(g, method, path, description, "", h)
}
// routeWithSchema registers an HTTP route with full parameter schema documentation.
// schema is injected verbatim into the API docs seen by the LLM.
func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) {
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
routeRegistry = append(routeRegistry, RouteDoc{
Method: method,
Path: fullPath,
Description: description,
Schema: schema,
})
switch method {
case "GET":
g.GET(path, h)
case "POST":
g.POST(path, h)
case "PUT":
g.PUT(path, h)
case "DELETE":
g.DELETE(path, h)
}
}
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
// Routes with schema documentation include full parameter details.
func GetAPIDocs() string {
var sb strings.Builder
for _, r := range routeRegistry {
sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path))
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
if r.Schema != "" {
// Indent each schema line for readability
for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") {
sb.WriteString(" ")
sb.WriteString(line)
sb.WriteByte('\n')
}
}
sb.WriteByte('\n')
}
return sb.String()
}

View File

@@ -39,14 +39,15 @@ import (
// Server HTTP API server
type Server struct {
router *gin.Engine
traderManager *manager.TraderManager
store *store.Store
cryptoHandler *CryptoHandler
backtestManager *backtest.Manager
debateHandler *DebateHandler
httpServer *http.Server
port int
router *gin.Engine
traderManager *manager.TraderManager
store *store.Store
cryptoHandler *CryptoHandler
backtestManager *backtest.Manager
debateHandler *DebateHandler
httpServer *http.Server
port int
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
}
// NewServer Creates API server
@@ -113,107 +114,181 @@ func (s *Server) setupRoutes() {
// Admin login (used in admin mode, public)
// System supported models and exchanges (no authentication required)
api.GET("/supported-models", s.handleGetSupportedModels)
api.GET("/supported-exchanges", s.handleGetSupportedExchanges)
s.route(api, "GET", "/supported-models", "List supported AI model providers", s.handleGetSupportedModels)
s.route(api, "GET", "/supported-exchanges", "List supported exchange types", s.handleGetSupportedExchanges)
// System config (no authentication required, for frontend to determine admin mode/registration status)
api.GET("/config", s.handleGetSystemConfig)
s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig)
// Crypto related endpoints (no authentication required)
// Crypto related endpoints (no authentication required, not exposed to bot)
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
// Public competition data (no authentication required)
api.GET("/traders", s.handlePublicTraderList)
api.GET("/competition", s.handlePublicCompetition)
api.GET("/top-traders", s.handleTopTraders)
api.GET("/equity-history", s.handleEquityHistory)
api.POST("/equity-history-batch", s.handleEquityHistoryBatch)
api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig)
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
s.route(api, "GET", "/competition", "Public competition data", s.handlePublicCompetition)
s.route(api, "GET", "/top-traders", "Top traders leaderboard", s.handleTopTraders)
s.route(api, "GET", "/equity-history", "Equity history for a trader", s.handleEquityHistory)
s.route(api, "POST", "/equity-history-batch", "Batch equity history for multiple traders", s.handleEquityHistoryBatch)
s.route(api, "GET", "/traders/:id/public-config", "Public trader configuration", s.handleGetPublicTraderConfig)
// Market data (no authentication required)
api.GET("/klines", s.handleKlines)
api.GET("/symbols", s.handleSymbols)
s.route(api, "GET", "/klines", "Candlestick data (?symbol=&interval=&limit=)", s.handleKlines)
s.route(api, "GET", "/symbols", "Available trading symbols", s.handleSymbols)
// Public strategy market (no authentication required)
api.GET("/strategies/public", s.handlePublicStrategies)
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
// Authentication related routes (no authentication required)
api.POST("/register", s.handleRegister)
api.POST("/login", s.handleLogin)
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
// Routes requiring authentication
protected := api.Group("/", s.authMiddleware())
{
// Logout (add to blacklist)
protected.POST("/logout", s.handleLogout)
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
// Server IP query (requires authentication, for whitelist configuration)
protected.GET("/server-ip", s.handleGetServerIP)
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
// AI trader management
protected.GET("/my-traders", s.handleTraderList)
protected.GET("/traders/:id/config", s.handleGetTraderConfig)
protected.POST("/traders", s.handleCreateTrader)
protected.PUT("/traders/:id", s.handleUpdateTrader)
protected.DELETE("/traders/:id", s.handleDeleteTrader)
protected.POST("/traders/:id/start", s.handleStartTrader)
protected.POST("/traders/:id/stop", s.handleStopTrader)
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
protected.POST("/traders/:id/close-position", s.handleClosePosition)
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
s.route(protected, "GET", "/my-traders", "List user's traders with status", s.handleTraderList)
s.route(protected, "GET", "/traders/:id/config", "Get full trader configuration", s.handleGetTraderConfig)
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
`Body: {"name":"<string, required>","ai_model_id":"<string, required — use ID from GET /api/models, must be enabled>","exchange_id":"<string, required — use ID from GET /api/exchanges, must be enabled>","strategy_id":"<string, optional — use ID from GET /api/strategies>","scan_interval_minutes":<int, default 60>}
Workflow: 1) GET /api/exchanges to find enabled exchange ID 2) GET /api/models to find enabled model ID 3) GET /api/strategies to find strategy ID 4) POST with all IDs`,
s.handleCreateTrader)
s.route(protected, "PUT", "/traders/:id", "Update trader configuration", s.handleUpdateTrader)
s.route(protected, "DELETE", "/traders/:id", "Delete trader", s.handleDeleteTrader)
s.route(protected, "POST", "/traders/:id/start", "Start trader — begins live trading", s.handleStartTrader)
s.route(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading", s.handleStopTrader)
s.routeWithSchema(protected, "PUT", "/traders/:id/prompt", "Override the trader's AI system prompt",
`Body: {"prompt":"<string — the full custom prompt text>"}`,
s.handleUpdateTraderPrompt)
s.route(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", s.handleSyncBalance)
s.routeWithSchema(protected, "POST", "/traders/:id/close-position", "Force-close an open position",
`Body: {"symbol":"<string, e.g. BTCUSDT>"}`,
s.handleClosePosition)
s.route(protected, "PUT", "/traders/:id/competition", "Toggle competition leaderboard visibility", s.handleToggleCompetition)
s.route(protected, "GET", "/traders/:id/grid-risk", "Get grid trading risk info", s.handleGetGridRiskInfo)
// AI model configuration
protected.GET("/models", s.handleGetModelConfigs)
protected.PUT("/models", s.handleUpdateModelConfigs)
s.route(protected, "GET", "/models", "List AI model configs — returns id, name, provider, enabled status", s.handleGetModelConfigs)
s.routeWithSchema(protected, "PUT", "/models", "Configure an AI model provider",
`Body: {"models":{"<model_id>":{"enabled":<bool>,"api_key":"<string>","custom_api_url":"<string, leave empty to use provider default>","custom_model_name":"<string, leave empty to use provider default>"}}}
model_id values: "openai","deepseek","qwen","kimi","grok","gemini","claude"
Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.deepseek.com, qwen→dashscope.aliyuncs.com/compatible-mode/v1, kimi→api.moonshot.ai/v1, grok→api.x.ai/v1, gemini→generativelanguage.googleapis.com/v1beta/openai, claude→api.anthropic.com/v1`,
s.handleUpdateModelConfigs)
// Exchange configuration
protected.GET("/exchanges", s.handleGetExchangeConfigs)
protected.POST("/exchanges", s.handleCreateExchange)
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
protected.DELETE("/exchanges/:id", s.handleDeleteExchange)
s.route(protected, "GET", "/exchanges", "List exchange accounts — returns id, exchange_type, account_name, enabled", s.handleGetExchangeConfigs)
s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account",
`Body: {"exchange_type":"<string>","account_name":"<string, user label>","enabled":true,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, required for okx/gate/kucoin>"}
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)
Required fields by exchange:
binance/bybit/bitget/indodax: api_key + secret_key
okx/gate/kucoin: api_key + secret_key + passphrase
hyperliquid: hyperliquid_wallet_addr
aster: aster_user + aster_signer + aster_private_key
lighter: lighter_wallet_addr + lighter_private_key + lighter_api_key_private_key + lighter_api_key_index`,
s.handleCreateExchange)
s.route(protected, "PUT", "/exchanges", "Update exchange configurations", s.handleUpdateExchangeConfigs)
s.route(protected, "DELETE", "/exchanges/:id", "Delete exchange account", s.handleDeleteExchange)
// Telegram bot configuration
s.route(protected, "GET", "/telegram", "Get Telegram bot configuration", s.handleGetTelegramConfig)
s.route(protected, "POST", "/telegram", "Update Telegram bot token/model", s.handleUpdateTelegramConfig)
s.route(protected, "POST", "/telegram/model", "Update Telegram bot AI model only", s.handleUpdateTelegramModel)
s.route(protected, "DELETE", "/telegram/binding", "Unbind Telegram account", s.handleUnbindTelegram)
// Strategy management
protected.GET("/strategies", s.handleGetStrategies)
protected.GET("/strategies/active", s.handleGetActiveStrategy)
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
protected.GET("/strategies/:id", s.handleGetStrategy)
protected.POST("/strategies", s.handleCreateStrategy)
protected.PUT("/strategies/:id", s.handleUpdateStrategy)
protected.DELETE("/strategies/:id", s.handleDeleteStrategy)
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy)
s.route(protected, "GET", "/strategies", "List user's strategies", s.handleGetStrategies)
s.route(protected, "GET", "/strategies/active", "Get active strategy", s.handleGetActiveStrategy)
s.route(protected, "GET", "/strategies/default-config", "Get default strategy config with all fields and sensible values — use as reference for building configs", s.handleGetDefaultStrategyConfig)
s.route(protected, "POST", "/strategies/preview-prompt", "Preview the AI prompt that will be generated from a config", s.handlePreviewPrompt)
s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun)
s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy)
s.routeWithSchema(protected, "POST", "/strategies", "Create a new trading strategy",
`Body: {"name":"<string, required>","description":"<string, optional>","lang":"zh|en","config":<StrategyConfig object>}
StrategyConfig fields:
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
indicators.klines.primary_count: number of candles (20-100)
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
indicators.klines.selected_timeframes: e.g. ["5m","15m","1h","4h"]
indicators.enable_raw_klines: ALWAYS true (raw OHLCV required)
indicators.enable_ema: true for trend-following (EMA crossover signals)
indicators.enable_macd: true for trend + momentum confirmation
indicators.enable_rsi: true for overbought/oversold, divergence detection
indicators.enable_boll: true for volatility, range trading, breakout strategies
indicators.enable_atr: true for volatility measurement and stop-loss sizing
indicators.enable_volume: ALWAYS true
indicators.enable_oi: ALWAYS true (open interest data)
indicators.enable_funding_rate: ALWAYS true
indicators.ema_periods: [20,50] default, [9,21] for faster signals
indicators.rsi_periods: [7,14] default
indicators.atr_periods: [14] default
indicators.boll_periods: [20] default
indicators.nofxos_api_key: ALWAYS "cm_568c67eae410d912c54c"
indicators.enable_quant_data: ALWAYS true
indicators.enable_quant_oi: ALWAYS true
indicators.enable_quant_netflow: ALWAYS true
indicators.enable_oi_ranking: ALWAYS true, oi_ranking_duration:"1h", oi_ranking_limit:10
indicators.enable_netflow_ranking: ALWAYS true, netflow_ranking_duration:"1h", netflow_ranking_limit:10
indicators.enable_price_ranking: ALWAYS true, price_ranking_duration:"1h,4h,24h", price_ranking_limit:10
risk_control.max_positions: max simultaneous positions (1=single coin, 3=diversified, 5=wide)
risk_control.btc_eth_max_leverage: BTC/ETH leverage (conservative:3-5, moderate:5-10, aggressive:10-20)
risk_control.altcoin_max_leverage: altcoin leverage (usually lower than BTC leverage)
risk_control.btc_eth_max_position_value_ratio: max position size as multiple of equity (default 5)
risk_control.altcoin_max_position_value_ratio: default 1
risk_control.max_margin_usage: 0.5-0.95 (default 0.9 = use up to 90% margin)
risk_control.min_position_size: minimum USDT per trade (default 12)
risk_control.min_risk_reward_ratio: minimum profit/loss ratio required (default 3 = 3:1)
risk_control.min_confidence: minimum AI confidence to open position (default 75, range 60-90)
prompt_sections.role_definition: describe the AI's trading persona and goal
prompt_sections.trading_frequency: guidelines on how often to trade
prompt_sections.entry_standards: conditions that must align before entering a position
prompt_sections.decision_process: step-by-step decision-making framework`,
s.handleCreateStrategy)
s.routeWithSchema(protected, "PUT", "/strategies/:id", "Update an existing strategy — WORKFLOW: 1) GET /api/strategies/:id first to read current config 2) Merge your changes into the full config 3) PUT with complete merged config 4) GET again to verify saved values",
`Body: {"name":"<string>","description":"<string>","config":<complete StrategyConfig — same structure as POST /api/strategies>}
IMPORTANT: config is merged with existing values server-side, but always send the complete section you are modifying.
After updating, always GET /api/strategies/:id to verify and show the user actual saved values.`,
s.handleUpdateStrategy)
s.route(protected, "DELETE", "/strategies/:id", "Delete strategy", s.handleDeleteStrategy)
s.route(protected, "POST", "/strategies/:id/activate", "Set strategy as active for a trader", s.handleActivateStrategy)
s.route(protected, "POST", "/strategies/:id/duplicate", "Duplicate strategy", s.handleDuplicateStrategy)
// Debate Arena
protected.GET("/debates", s.debateHandler.HandleListDebates)
protected.GET("/debates/personalities", s.debateHandler.HandleGetPersonalities)
protected.GET("/debates/:id", s.debateHandler.HandleGetDebate)
protected.POST("/debates", s.debateHandler.HandleCreateDebate)
protected.POST("/debates/:id/start", s.debateHandler.HandleStartDebate)
protected.POST("/debates/:id/cancel", s.debateHandler.HandleCancelDebate)
protected.POST("/debates/:id/execute", s.debateHandler.HandleExecuteDebate)
protected.DELETE("/debates/:id", s.debateHandler.HandleDeleteDebate)
protected.GET("/debates/:id/messages", s.debateHandler.HandleGetMessages)
protected.GET("/debates/:id/votes", s.debateHandler.HandleGetVotes)
protected.GET("/debates/:id/stream", s.debateHandler.HandleDebateStream)
s.route(protected, "GET", "/debates", "List debates", s.debateHandler.HandleListDebates)
s.route(protected, "GET", "/debates/personalities", "Available AI personalities", s.debateHandler.HandleGetPersonalities)
s.route(protected, "GET", "/debates/:id", "Get debate details", s.debateHandler.HandleGetDebate)
s.route(protected, "POST", "/debates", "Create debate", s.debateHandler.HandleCreateDebate)
s.route(protected, "POST", "/debates/:id/start", "Start debate", s.debateHandler.HandleStartDebate)
s.route(protected, "POST", "/debates/:id/cancel", "Cancel debate", s.debateHandler.HandleCancelDebate)
s.route(protected, "POST", "/debates/:id/execute", "Execute debate consensus decision", s.debateHandler.HandleExecuteDebate)
s.route(protected, "DELETE", "/debates/:id", "Delete debate", s.debateHandler.HandleDeleteDebate)
s.route(protected, "GET", "/debates/:id/messages", "Get debate messages", s.debateHandler.HandleGetMessages)
s.route(protected, "GET", "/debates/:id/votes", "Get debate votes", s.debateHandler.HandleGetVotes)
s.route(protected, "GET", "/debates/:id/stream", "SSE stream for live debate", s.debateHandler.HandleDebateStream)
// Data for specified trader (using query parameter ?trader_id=xxx)
protected.GET("/status", s.handleStatus)
protected.GET("/account", s.handleAccount)
protected.GET("/positions", s.handlePositions)
protected.GET("/positions/history", s.handlePositionHistory)
protected.GET("/trades", s.handleTrades)
protected.GET("/orders", s.handleOrders) // Order list (all orders)
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
protected.GET("/decisions", s.handleDecisions)
protected.GET("/decisions/latest", s.handleLatestDecisions)
protected.GET("/statistics", s.handleStatistics)
s.route(protected, "GET", "/status", "Trader running status (?trader_id=)", s.handleStatus)
s.route(protected, "GET", "/account", "Account balance and equity (?trader_id=)", s.handleAccount)
s.route(protected, "GET", "/positions", "Current open positions (?trader_id=)", s.handlePositions)
s.route(protected, "GET", "/positions/history", "Position history (?trader_id=)", s.handlePositionHistory)
s.route(protected, "GET", "/trades", "Trade records (?trader_id=)", s.handleTrades)
s.route(protected, "GET", "/orders", "All orders (?trader_id=)", s.handleOrders)
s.route(protected, "GET", "/orders/:id/fills", "Order fill details", s.handleOrderFills)
s.route(protected, "GET", "/open-orders", "Open orders from exchange (?trader_id=)", s.handleOpenOrders)
s.route(protected, "GET", "/decisions", "AI trading decisions (?trader_id=)", s.handleDecisions)
s.route(protected, "GET", "/decisions/latest", "Latest AI decisions (?trader_id=)", s.handleLatestDecisions)
s.route(protected, "GET", "/statistics", "Trading statistics (?trader_id=)", s.handleStatistics)
// Backtest routes
backtest := protected.Group("/backtest")
@@ -3611,3 +3686,106 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
// SetTelegramReloadCh sets the channel used to signal the Telegram bot to reload
func (s *Server) SetTelegramReloadCh(ch chan<- struct{}) {
s.telegramReloadCh = ch
}
// handleGetTelegramConfig returns current Telegram bot configuration and binding status
func (s *Server) handleGetTelegramConfig(c *gin.Context) {
cfg, err := s.store.TelegramConfig().Get()
if err != nil {
// Not configured yet - return empty state
c.JSON(http.StatusOK, gin.H{
"configured": false,
"is_bound": false,
"token_masked": "",
"username": "",
})
return
}
// Mask bot token for security (show only last 6 chars)
tokenMasked := ""
if cfg.BotToken != "" {
if len(cfg.BotToken) > 6 {
tokenMasked = "***" + cfg.BotToken[len(cfg.BotToken)-6:]
} else {
tokenMasked = "***"
}
}
c.JSON(http.StatusOK, gin.H{
"configured": cfg.BotToken != "",
"is_bound": cfg.ChatID != 0,
"username": cfg.Username,
"bound_at": cfg.BoundAt,
"token_masked": tokenMasked,
"model_id": cfg.ModelID,
})
}
// handleUpdateTelegramConfig saves bot token (+ optional model ID) and triggers bot hot-reload
func (s *Server) handleUpdateTelegramConfig(c *gin.Context) {
var req struct {
BotToken string `json:"bot_token"`
ModelID string `json:"model_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.BotToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
return
}
if err := s.store.TelegramConfig().Save(req.BotToken, req.ModelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
return
}
// Signal bot hot-reload if channel is available
if s.telegramReloadCh != nil {
select {
case s.telegramReloadCh <- struct{}{}:
default: // non-blocking
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Bot token saved. Bot will reload automatically."})
}
// handleUnbindTelegram removes Telegram user binding
func (s *Server) handleUnbindTelegram(c *gin.Context) {
if err := s.store.TelegramConfig().Unbind(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unbind"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Telegram binding removed"})
}
// handleUpdateTelegramModel updates only the AI model used for Telegram replies (no token re-entry needed)
func (s *Server) handleUpdateTelegramModel(c *gin.Context) {
var req struct {
ModelID string `json:"model_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
cfg, err := s.store.TelegramConfig().Get()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no Telegram config found, save a bot token first"})
return
}
if err := s.store.TelegramConfig().Save(cfg.BotToken, req.ModelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model config"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "model_id": req.ModelID})
}

View File

@@ -136,7 +136,8 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
})
}
// handleCreateStrategy Create strategy
// handleCreateStrategy Create strategy.
// If "config" is omitted from the request body, the system default config is used automatically.
func (s *Server) handleCreateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
@@ -145,9 +146,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -155,6 +157,16 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
return
}
// Use default config when none provided
if req.Config == nil {
lang := req.Lang
if lang == "" {
lang = "zh"
}
defaultCfg := store.GetDefaultStrategyConfig(lang)
req.Config = &defaultCfg
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
if err != nil {
@@ -178,7 +190,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
// Validate configuration and collect warnings
warnings := validateStrategyConfig(&req.Config)
warnings := validateStrategyConfig(req.Config)
response := gin.H{
"id": strategy.ID,
@@ -191,7 +203,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// handleUpdateStrategy Update strategy
// handleUpdateStrategy Update strategy.
// The incoming config is merged with the existing one: top-level sections present in the
// request overwrite the corresponding existing sections; absent sections are preserved.
// This prevents partial updates from zeroing out unmentioned fields.
func (s *Server) handleUpdateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
@@ -213,11 +228,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config"`
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
Name string `json:"name"`
Description string `json:"description"`
Config json.RawMessage `json:"config"` // raw JSON so we can merge
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -225,8 +240,33 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
// Start with the existing config as base — preserves all unmentioned fields.
var mergedConfig store.StrategyConfig
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
// If existing config is corrupt, start from zero
mergedConfig = store.StrategyConfig{}
}
// Apply incoming config on top: top-level sections present in the request overwrite
// their corresponding existing section; absent sections remain unchanged.
if len(req.Config) > 0 && string(req.Config) != "null" {
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
}
// Preserve existing name/description when not supplied
name := req.Name
if name == "" {
name = existing.Name
}
description := req.Description
if description == "" {
description = existing.Description
}
configJSON, err := json.Marshal(mergedConfig)
if err != nil {
SafeInternalError(c, "Serialize configuration", err)
return
@@ -235,8 +275,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
strategy := &store.Strategy{
ID: strategyID,
UserID: userID,
Name: req.Name,
Description: req.Description,
Name: name,
Description: description,
Config: string(configJSON),
IsPublic: req.IsPublic,
ConfigVisible: req.ConfigVisible,
@@ -247,8 +287,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Validate configuration and collect warnings
warnings := validateStrategyConfig(&req.Config)
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"nofx/experience"
"nofx/mcp"
"os"
@@ -44,6 +45,10 @@ type Config struct {
AlpacaAPIKey string // Alpaca API key for US stocks
AlpacaSecretKey string // Alpaca secret key
TwelveDataKey string // TwelveData API key for forex & metals
// Telegram Bot configuration
TelegramBotToken string // TELEGRAM_BOT_TOKEN (required to enable bot)
TelegramAdminChatID int64 // TELEGRAM_ADMIN_CHAT_ID (optional, 0 = auto-bind on first /start)
}
// Init initializes global configuration (from .env)
@@ -104,6 +109,17 @@ func Init() {
cfg.AlpacaSecretKey = os.Getenv("ALPACA_SECRET_KEY")
cfg.TwelveDataKey = os.Getenv("TWELVEDATA_API_KEY")
// Telegram Bot configuration
cfg.TelegramBotToken = os.Getenv("TELEGRAM_BOT_TOKEN")
if chatIDStr := os.Getenv("TELEGRAM_ADMIN_CHAT_ID"); chatIDStr != "" {
if id, err := strconv.ParseInt(chatIDStr, 10, 64); err == nil {
cfg.TelegramAdminChatID = id
} else {
// logger may not be init yet, use fmt
fmt.Printf("WARNING: TELEGRAM_ADMIN_CHAT_ID invalid value %q, ignoring: %v\n", chatIDStr, err)
}
}
// Database configuration
if v := os.Getenv("DB_TYPE"); v != "" {
cfg.DBType = strings.ToLower(v)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

10
main.go
View File

@@ -11,6 +11,7 @@ import (
"nofx/manager"
"nofx/mcp"
"nofx/store"
"nofx/telegram"
"os"
"os/signal"
"path/filepath"
@@ -130,12 +131,21 @@ func main() {
// Start API server
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
// Create hot-reload channel for Telegram bot; wire it to the API server
// so that POST /api/telegram can trigger a bot restart when the token changes.
telegramReloadCh := make(chan struct{}, 1)
server.SetTelegramReloadCh(telegramReloadCh)
go func() {
if err := server.Start(); err != nil {
logger.Fatalf("❌ Failed to start API server: %v", err)
}
}()
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
go telegram.Start(cfg, st, telegramReloadCh)
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

View File

@@ -1,7 +1,9 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -544,3 +546,124 @@ func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
return requestBody
}
// CallWithRequestStream streams the LLM response via SSE (Server-Sent Events).
// onChunk is called with the full accumulated text so far after each received chunk.
// Returns the complete final text when the stream ends.
//
// Idle timeout: if no chunk arrives for 30 seconds the stream is cancelled automatically.
// This prevents the scanner from blocking indefinitely on a hung or stalled connection.
func (client *Client) CallWithRequestStream(req *Request, onChunk func(string)) (string, error) {
if client.APIKey == "" {
return "", fmt.Errorf("AI API key not set")
}
if req.Model == "" {
req.Model = client.Model
}
req.Stream = true
requestBody := client.buildRequestBodyFromRequest(req)
jsonData, err := client.hooks.marshalRequestBody(requestBody)
if err != nil {
return "", err
}
url := client.hooks.buildUrl()
httpReq, err := client.hooks.buildRequest(url, jsonData)
if err != nil {
return "", err
}
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 30 seconds.
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
const idleTimeout = 60 * time.Second
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resetCh := make(chan struct{}, 1)
go func() {
t := time.NewTimer(idleTimeout)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
cancel() // idle timeout: kill the connection
return
case <-resetCh:
// received a line — reset the idle timer
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(idleTimeout)
}
}
}()
httpReq = httpReq.WithContext(ctx)
resp, err := client.httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("streaming request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var accumulated strings.Builder
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
// Ping the watchdog: we received a line, reset the idle timer.
select {
case resetCh <- struct{}{}:
default:
}
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
// Parse the SSE JSON chunk
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // skip malformed chunks
}
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta.Content
if delta == "" {
continue
}
accumulated.WriteString(delta)
if onChunk != nil {
onChunk(accumulated.String())
}
}
if err := scanner.Err(); err != nil {
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
}
return accumulated.String(), nil
}

View File

@@ -10,7 +10,11 @@ type AIClient interface {
SetAPIKey(apiKey string, customURL string, customModel string)
SetTimeout(timeout time.Duration)
CallWithMessages(systemPrompt, userPrompt string) (string, error)
CallWithRequest(req *Request) (string, error) // Builder pattern API (supports advanced features)
CallWithRequest(req *Request) (string, error)
// CallWithRequestStream streams the LLM response via SSE.
// onChunk is called with the full accumulated text so far (not raw deltas).
// Returns the complete final text when done.
CallWithRequestStream(req *Request, onChunk func(string)) (string, error)
}
// clientHooks internal hook interface (for subclass to override specific steps)

View File

@@ -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 {

View File

@@ -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
View 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
}

228
telegram/agent/agent.go Normal file
View File

@@ -0,0 +1,228 @@
package agent
import (
"encoding/json"
"fmt"
"nofx/auth"
"nofx/logger"
"nofx/mcp"
"nofx/telegram/session"
"strings"
)
const maxIterations = 10
// Agent is a stateful AI agent for one Telegram chat.
// It has a single tool (api_call) and an unbounded decision loop.
type Agent struct {
apiTool *apiCallTool
getLLM func() mcp.AIClient
memory *session.Memory
systemPrompt string
userID string
}
// New creates an Agent for one chat session.
func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
return &Agent{
apiTool: newAPICallTool(apiPort, botToken),
getLLM: getLLM,
memory: session.NewMemory(getLLM()),
systemPrompt: systemPrompt,
userID: userID,
}
}
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
// userID must match the actual registered user's ID so that bot-made changes
// are visible in the frontend (they share the same user namespace).
func GenerateBotToken(userID string) (string, error) {
return auth.GenerateJWT(userID, "bot@internal")
}
// buildAccountContext fetches the live account state (models, exchanges, strategies, traders,
// and per-trader account summary + statistics) via the local API and returns it as a formatted
// string for injection into the LLM context. This gives the LLM immediate awareness of what
// is already configured and the current financial state, so it never asks the user for
// information that already exists.
func (a *Agent) buildAccountContext() string {
type q struct {
label string
path string
}
queries := []q{
{"AI Models", "/api/models"},
{"Exchanges", "/api/exchanges"},
{"Strategies", "/api/strategies"},
{"Traders", "/api/my-traders"},
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[Current Account State - Authenticated User ID: %s]\n\n", a.userID))
var tradersJSON string
for _, query := range queries {
result := a.apiTool.execute(&apiRequest{Method: "GET", Path: query.path})
sb.WriteString(fmt.Sprintf("%s:\n%s\n\n", query.label, result))
if query.path == "/api/my-traders" {
tradersJSON = result
}
}
// For each running trader, fetch real-time account balance and trading statistics.
var traders []struct {
TraderID string `json:"trader_id"`
Name string `json:"trader_name"`
IsRunning bool `json:"is_running"`
}
if err := json.Unmarshal([]byte(tradersJSON), &traders); err == nil {
for _, t := range traders {
if !t.IsRunning {
continue
}
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Account [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, acct))
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Statistics [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, stats))
}
}
return sb.String()
}
// Run processes one user message through the agent loop.
// Loop: LLM decides -> if <api_call>: execute, append result, loop -> if no tag: return reply.
//
// On the first message of a conversation, the current account state (models, exchanges,
// strategies, traders) is automatically fetched and injected so the LLM knows what is
// already configured without asking the user to repeat themselves.
//
// onChunk is optional. When non-nil, each LLM call is streamed:
// - Chunks are forwarded to onChunk until an <api_call> tag appears in the accumulated text.
// - After an api_call iteration completes, onChunk("⏳") resets the display to a thinking indicator.
// - The final reply is streamed progressively via onChunk.
func (a *Agent) Run(userMessage string, onChunk func(string)) string {
llm := a.getLLM()
if llm == nil {
return "AI assistant unavailable. Please configure an AI model in the Web UI."
}
// Build turn messages: history context prefix + current user message.
// On the very first message (no history), prepend a live account state snapshot so the
// LLM immediately knows what models, exchanges, strategies, and traders are configured.
histCtx := a.memory.BuildContext()
var firstMsg string
if histCtx == "" {
// First message in this conversation — fetch and inject account state.
accountCtx := a.buildAccountContext()
firstMsg = accountCtx + "\n[User Message]\n" + userMessage
} else {
firstMsg = histCtx + "\n---\nUser: " + userMessage
}
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstMsg)}
var lastResp string
for i := 0; i < maxIterations; i++ {
req, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
Build()
if err != nil {
logger.Errorf("Agent: failed to build request: %v", err)
break
}
var resp string
if onChunk != nil {
// Stream this call; suppress chunks once an <api_call> tag appears.
// Also hold back the last (len("<api_call>")-1) chars of accumulated text to
// avoid showing partial opening tags (e.g. "<", "<ap") before we can detect them.
const tagLen = len("<api_call>") // 10
const safeOffset = tagLen - 1 // 9: max prefix of tag we might have received
var apiTagSeen bool
resp, err = llm.CallWithRequestStream(req, func(accumulated string) {
if apiTagSeen {
return
}
if idx := strings.Index(accumulated, "<api_call>"); idx >= 0 {
apiTagSeen = true
// Forward only the text that appeared before the tag.
if display := strings.TrimSpace(accumulated[:idx]); display != "" {
onChunk(display)
}
return
}
// Forward only the "safe" prefix — hold back the last safeOffset chars
// in case they are the beginning of an <api_call> tag.
if safe := len(accumulated) - safeOffset; safe > 0 {
onChunk(accumulated[:safe])
}
})
} else {
resp, err = llm.CallWithRequest(req)
}
if err != nil {
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
return "AI assistant temporarily unavailable. Please try again."
}
lastResp = resp
apiReq, textBefore := parseAPICall(resp)
if apiReq == nil {
// No api_call tag — LLM gave a final answer (already streamed if onChunk set).
reply := stripAPICallTag(strings.TrimSpace(resp))
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// api_call iteration — reset display to thinking indicator before executing.
if onChunk != nil {
onChunk("⏳")
}
logger.Infof("Agent: iter=%d %s %s", i+1, apiReq.Method, apiReq.Path)
result := a.apiTool.execute(apiReq)
if textBefore != "" {
turnMsgs = append(turnMsgs, mcp.NewAssistantMessage(textBefore))
}
turnMsgs = append(turnMsgs, mcp.NewUserMessage(
fmt.Sprintf("[API result: %s %s]\n%s", apiReq.Method, apiReq.Path, result),
))
}
// Safety: max iterations reached — ask LLM for a final summary (non-streaming).
logger.Warnf("Agent: max iterations (%d) reached", maxIterations)
turnMsgs = append(turnMsgs, mcp.NewUserMessage("Please summarize the results and give the user a final reply."))
if finalReq, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
Build(); err == nil {
if finalResp, err := llm.CallWithRequest(finalReq); err == nil {
lastResp = finalResp
}
}
reply := stripAPICallTag(strings.TrimSpace(lastResp))
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// stripAPICallTag removes any <api_call>...</api_call> fragment from s.
// Used as a defensive layer to ensure tags never leak to the user.
func stripAPICallTag(s string) string {
if idx := strings.Index(s, "<api_call>"); idx >= 0 {
return strings.TrimSpace(s[:idx])
}
return s
}
// ResetMemory clears conversation history (called on /start).
func (a *Agent) ResetMemory() {
a.memory.ResetFull()
}

View File

@@ -0,0 +1,183 @@
package agent
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"nofx/mcp"
)
type mockLLM struct {
responses []string
calls int
lastMsgs []mcp.Message
}
func (m *mockLLM) SetAPIKey(_, _, _ string) {}
func (m *mockLLM) SetTimeout(_ time.Duration) {}
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return m.next() }
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
m.lastMsgs = req.Messages
return m.next()
}
func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
m.lastMsgs = req.Messages
r, err := m.next()
if onChunk != nil {
onChunk(r)
}
return r, err
}
func (m *mockLLM) next() (string, error) {
if m.calls < len(m.responses) {
r := m.responses[m.calls]
m.calls++
return r, nil
}
return "OK", nil
}
func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
return func() mcp.AIClient { return llm }
}
const testPrompt = "You are a test assistant."
// TestAgentDirectReply: LLM replies without api_call — one call, direct reply.
func TestAgentDirectReply(t *testing.T) {
llm := &mockLLM{responses: []string{"Hello! How can I help you?"}}
a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("hello", nil)
if reply != "Hello! How can I help you?" {
t.Fatalf("unexpected reply: %q", reply)
}
if llm.calls != 1 {
t.Fatalf("expected 1 LLM call, got %d", llm.calls)
}
}
// TestAgentAPICall: LLM calls API, gets result, gives final reply — two LLM calls.
func TestAgentAPICall(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/my-traders" {
w.Write([]byte(`[{"id":"t1","name":"BTC Strategy"}]`)) //nolint:errcheck
return
}
w.WriteHeader(404)
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []string{
`Let me check.<api_call>{"method":"GET","path":"/api/my-traders","body":{}}</api_call>`,
"You have one trader: BTC Strategy.",
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("list my traders", nil)
if reply != "You have one trader: BTC Strategy." {
t.Fatalf("unexpected reply: %q", reply)
}
if llm.calls != 2 {
t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
}
}
// TestAgentMultiStep: LLM chains two API calls before final reply — three LLM calls.
func TestAgentMultiStep(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []string{
`Checking account.<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
`Now checking positions.<api_call>{"method":"GET","path":"/api/positions","body":{}}</api_call>`,
"Account looks healthy and no open positions.",
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("show me account status", nil)
if llm.calls != 3 {
t.Fatalf("expected 3 LLM calls (2 api + 1 final), got %d", llm.calls)
}
if reply != "Account looks healthy and no open positions." {
t.Fatalf("unexpected final reply: %q", reply)
}
}
// TestAgentAPIResultInContext: API result must appear in next LLM message.
func TestAgentAPIResultInContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"balance":1234.56}`)) //nolint:errcheck
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []string{
`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
"Balance is 1234.56 USDT.",
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
a.Run("show balance", nil)
found := false
for _, msg := range llm.lastMsgs {
if strings.Contains(msg.Content, "API result") || strings.Contains(msg.Content, "balance") {
found = true
break
}
}
if !found {
t.Fatalf("API result not found in subsequent LLM context")
}
}
// TestParseAPICall: unit tests for the XML tag parser.
func TestParseAPICall(t *testing.T) {
t.Run("valid call", func(t *testing.T) {
resp := `Stopping trader.<api_call>{"method":"POST","path":"/api/traders/t1/stop","body":{}}</api_call>`
req, text := parseAPICall(resp)
if req == nil {
t.Fatal("expected api_call, got nil")
}
if req.Method != "POST" || req.Path != "/api/traders/t1/stop" {
t.Fatalf("unexpected req: %+v", req)
}
if text != "Stopping trader." {
t.Fatalf("unexpected text before tag: %q", text)
}
})
t.Run("no call tag", func(t *testing.T) {
req, text := parseAPICall("Just a reply.")
if req != nil {
t.Fatal("expected nil api_call")
}
if text != "Just a reply." {
t.Fatalf("expected original text, got %q", text)
}
})
t.Run("malformed JSON", func(t *testing.T) {
req, _ := parseAPICall(`<api_call>NOT JSON</api_call>`)
if req != nil {
t.Fatal("expected nil for malformed JSON")
}
})
}

109
telegram/agent/apicall.go Normal file
View File

@@ -0,0 +1,109 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strings"
"time"
)
// apiCallTool executes HTTP requests against the NOFX API server.
// This is the only tool available to the agent.
type apiCallTool struct {
baseURL string
token string
client *http.Client
}
// apiRequest is the parsed structure from the LLM's <api_call> tag.
type apiRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Body map[string]any `json:"body"`
}
func newAPICallTool(port int, token string) *apiCallTool {
return &apiCallTool{
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
token: token,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// execute calls the API and returns the response as a string for LLM consumption.
func (t *apiCallTool) execute(req *apiRequest) string {
if req.Method == "" || req.Path == "" {
return "error: method and path are required"
}
if !strings.HasPrefix(req.Path, "/") {
req.Path = "/" + req.Path
}
var bodyReader io.Reader
if req.Method != "GET" && len(req.Body) > 0 {
b, err := json.Marshal(req.Body)
if err != nil {
return fmt.Sprintf("error marshaling body: %v", err)
}
bodyReader = bytes.NewReader(b)
}
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
if err != nil {
return fmt.Sprintf("error creating request: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+t.token)
resp, err := t.client.Do(httpReq)
if err != nil {
return fmt.Sprintf("API call failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Sprintf("error reading response: %v", err)
}
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
if resp.StatusCode >= 400 {
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
}
// Pretty-print JSON for better LLM readability
var v any
if json.Unmarshal(body, &v) == nil {
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
return string(pretty)
}
}
return string(body)
}
// parseAPICall extracts <api_call>...</api_call> from LLM response.
// Returns (nil, original) if not found or malformed JSON.
func parseAPICall(resp string) (*apiRequest, string) {
const openTag = "<api_call>"
const closeTag = "</api_call>"
start := strings.Index(resp, openTag)
end := strings.Index(resp, closeTag)
if start < 0 || end < 0 || end <= start {
return nil, resp
}
jsonStr := strings.TrimSpace(resp[start+len(openTag) : end])
var req apiRequest
if err := json.Unmarshal([]byte(jsonStr), &req); err != nil {
logger.Warnf("Agent: failed to parse api_call JSON %q: %v", jsonStr, err)
return nil, resp
}
return &req, strings.TrimSpace(resp[:start])
}

78
telegram/agent/manager.go Normal file
View File

@@ -0,0 +1,78 @@
package agent
import (
"nofx/logger"
"nofx/mcp"
"sync"
"time"
)
// Manager holds one Agent per Telegram chat ID.
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
type Manager struct {
mu sync.Mutex
agents map[int64]*Agent
lanes map[int64]chan struct{}
apiPort int
botToken string
userID string
getLLM func() mcp.AIClient
systemPrompt string
}
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
// userID is the database user ID the bot authenticates as (used in system prompt context).
func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
return &Manager{
agents: make(map[int64]*Agent),
lanes: make(map[int64]chan struct{}),
apiPort: apiPort,
botToken: botToken,
userID: userID,
getLLM: getLLM,
systemPrompt: BuildAgentPrompt(apiDocs, userID),
}
}
// Run processes a message for the given chat ID.
// If the same chat is already processing a message, this call blocks until it completes
// or the lane wait times out (60 s), whichever comes first.
// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming).
func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string {
a, lane := m.getOrCreate(chatID)
select {
case lane <- struct{}{}:
case <-time.After(60 * time.Second):
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
return "上一条消息仍在处理中,请稍等片刻后再试。"
}
defer func() { <-lane }()
return a.Run(userMessage, onChunk)
}
// Reset clears memory for the given chat (called on /start).
func (m *Manager) Reset(chatID int64) {
m.mu.Lock()
a, ok := m.agents[chatID]
m.mu.Unlock()
if ok {
a.ResetMemory()
}
}
func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.agents[chatID]
if !ok {
a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt)
m.agents[chatID] = a
}
lane, ok := m.lanes[chatID]
if !ok {
lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
m.lanes[chatID] = lane
}
return a, lane
}

107
telegram/agent/prompt.go Normal file
View File

@@ -0,0 +1,107 @@
package agent
import "fmt"
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
// userID is the actual database user ID the bot authenticates as.
func BuildAgentPrompt(apiDocs, userID string) string {
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
## Your Identity
- You are authenticated as user ID: %s
- All API calls are made on behalf of this user
- When asked "which user / username / email" — answer with this user ID directly, no API call needed
## Tool: api_call
Append EXACTLY ONE tag at the very end of your reply when you need to call the API:
<api_call>{"method":"GET","path":"/api/xxx","body":{}}</api_call>
Rules:
- The tag must be the LAST thing in your message — nothing after it
- NEVER more than one <api_call> tag per response
- 【CRITICAL】NEVER say "让我查询..."、"现在获取..."、"I will call..."、"Let me check..." — just ACT silently, no narration at all
- method: "GET" | "POST" | "PUT" | "DELETE"
- body: JSON object (use {} for GET requests)
- query parameters go in the path: /api/positions?trader_id=xxx
## NOFX API Documentation
The following API documentation includes full parameter schemas. Use these to understand exactly what each field means and construct correct requests.
%s
## Behavior Rules
1. 【NO NARRATION】Never tell the user what API you are calling. Zero narration. Just act.
2. Only ONE <api_call> tag per response, always at the very end
3. After getting an API result, decide: call another API or give a final reply
4. If the API returns success (2xx), the operation succeeded — do not retry
5. Reply in the same language the user used (中文→中文, English→English)
6. Keep replies concise — show results, not process
7. Ask for ALL required information in ONE message — never ask one field at a time
8. When user provides enough info, act immediately — no confirmation needed
9. Be decisive — infer intent from context, use schema to fill in smart defaults
## Verification Rule (CRITICAL)
After ANY PUT or POST that creates or modifies a resource:
1. Immediately GET the resource to read actual saved values
2. Show the user the KEY fields they care about from the GET response
3. NEVER just say "updated successfully" without showing the actual values
4. If saved values look wrong, correct them automatically
## Error Handling
- 400: explain what was wrong, ask user to correct
- 404: resource doesn't exist, check IDs
- "AI model not enabled": tell user to enable the model first via PUT /api/models
- "Exchange not enabled": tell user to enable the exchange first
- 5xx: server error, ask user to try again
- stream interrupted / unavailable: apologize briefly and ask user to retry
## How to Use the API Schema
All API knowledge comes from the documentation above. Use field descriptions to:
- Know exactly which fields are required vs optional
- Understand semantics and build correct request bodies from natural language
- For StrategyConfig: intelligently fill all fields based on user's trading style
## Account State (injected at conversation start)
At the start of each new conversation, a [Current Account State] block is provided with:
- AI Models: all configured models with their IDs and enabled status
- Exchanges: all configured exchanges with their IDs and enabled status
- Strategies: all existing strategies with their IDs
- Traders: all existing traders with their IDs and running status
Use this to:
- NEVER ask for exchange/model info that is already configured — use the existing IDs directly
- Know instantly if the user has 0 or N resources of each type
- If only one exchange/model exists and user doesn't specify, use it directly without asking
- If multiple exist, list them and ask which one to use
## Common Workflows
**Configure model**: Ask only for api_key. Set enabled:true, send empty strings for URL/model (backend applies provider defaults).
**Configure exchange**: Ask for all required fields in ONE message (see schema). Always set enabled:true.
**Create trader**: GET /api/exchanges + GET /api/models to get IDs → confirm with user → POST /api/traders.
**Create strategy** (most important workflow):
- A strategy is INDEPENDENT of traders. Never GET trader info just to create a strategy.
- If user specifies style + coins (e.g. "BTC trend"), build and POST immediately — no questions needed.
- Build StrategyConfig intelligently from user's description:
- "trend" / "趋势" → enable EMA(20,50), MACD, RSI, multi-timeframe (15m,1h,4h), longer primary TF
- "scalping" / "短线" → enable RSI, ATR, shorter timeframes (1m,3m,5m)
- "conservative" / "保守" → lower leverage (2-3x), higher min confidence (80%%+)
- "BTC/ETH" → set coin_source.source_type="static", static_coins=["BTC/USDT"] or similar
- After POST: GET /api/strategies/:id to verify → show user: name, coins, key indicators, leverage
**Update strategy config**:
1. GET /api/strategies/:id to read current full config
2. Modify only what user asked (keep all other fields)
3. PUT /api/strategies/:id with complete merged config
4. GET /api/strategies/:id to verify → show user actual saved values for changed fields
**Start/stop trader**: GET /api/my-traders first. If only one trader, act directly. If multiple, list and ask.
**Query data**: GET /api/my-traders to get trader_id, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userID, apiDocs)
}

310
telegram/bot.go Normal file
View File

@@ -0,0 +1,310 @@
package telegram
import (
"nofx/api"
"nofx/config"
"nofx/logger"
"nofx/mcp"
"nofx/store"
"nofx/telegram/agent"
"os"
"sync"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// Start initializes and runs the Telegram bot in a blocking supervisor loop.
// Supports hot-reload: when a signal is sent on reloadCh, the bot restarts
// with the latest token (re-read from DB or env). Must be called as a goroutine from main.go.
// Deployment note: uses long-polling (not webhook) — safe for private networks,
// no inbound ports required.
func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
for {
token := resolveToken(cfg, st)
if token == "" {
logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...")
// Block until a reload signal arrives, then re-check for a token.
<-reloadCh
continue
}
stopped := runBot(token, cfg, st)
if !stopped {
// Bot exited with an unrecoverable error; do not restart automatically.
return
}
// Bot was stopped cleanly. Wait for a reload signal before restarting.
select {
case <-reloadCh:
logger.Info("Reloading Telegram bot with new token...")
}
}
}
// resolveToken returns the bot token, preferring the DB-stored value over the env/config value.
func resolveToken(cfg *config.Config, st *store.Store) string {
dbCfg, err := st.TelegramConfig().Get()
if err == nil && dbCfg.BotToken != "" {
return dbCfg.BotToken
}
return cfg.TelegramBotToken
}
// runBot runs the bot until StopReceivingUpdates is called (clean stop → true)
// or a fatal error occurs (false).
func runBot(token string, cfg *config.Config, st *store.Store) bool {
bot, err := tgbotapi.NewBotAPI(token)
if err != nil {
logger.Errorf("Telegram bot failed to start: %v", err)
return false
}
logger.Infof("Telegram bot @%s started (long-polling mode)", bot.Self.UserName)
// Determine allowed chat ID:
// Priority 1: env var TELEGRAM_ADMIN_CHAT_ID (explicit)
// Priority 2: DB-stored bound chat ID (set by /start)
// Priority 3: 0 = no binding yet, first /start will bind
allowedChatID := cfg.TelegramAdminChatID
if allowedChatID == 0 {
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
allowedChatID = id
}
}
// Resolve the real user ID: use the first registered user so that bot-made
// changes (model/exchange configs) are visible in the frontend under that user.
// Falls back to "default" if no users exist yet (fresh install).
botUserID := "default"
if ids, err := st.User().GetAllIDs(); err == nil && len(ids) > 0 {
botUserID = ids[0]
}
// Generate a bot JWT for authenticated API calls. Re-generated on each bot start.
botToken, err := agent.GenerateBotToken(botUserID)
if err != nil {
logger.Errorf("Failed to generate bot JWT: %v", err)
return false
}
// Wire the AI agent manager. API docs are auto-generated from registered routes.
agents := agent.NewManager(cfg.APIServerPort, botToken, botUserID,
func() mcp.AIClient { return newLLMClient(st) },
api.GetAPIDocs(),
)
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := bot.GetUpdatesChan(u)
for update := range updates {
if update.Message == nil {
continue
}
chatID := update.Message.Chat.ID
text := update.Message.Text
// Handle /start: auto-bind or welcome
if text == "/start" {
if allowedChatID == 0 {
// First user to /start becomes the bound admin
username := update.Message.From.UserName
if err := st.TelegramConfig().BindUser(chatID, "@"+username); err != nil {
logger.Errorf("Failed to bind Telegram user: %v", err)
sendMsg(bot, chatID, "Binding failed, please check server logs.")
continue
}
allowedChatID = chatID
logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID)
sendMsg(bot, chatID, "Bound successfully! "+welcomeMessage())
} else if chatID == allowedChatID {
// Already bound, same user: reset session and show welcome
agents.Reset(chatID)
sendMsg(bot, chatID, welcomeMessage())
} else {
sendMsg(bot, chatID, "This bot is already bound to another user.")
}
continue
}
// Handle /help
if text == "/help" {
sendMsg(bot, chatID, helpMessage())
continue
}
// Access control
if allowedChatID != 0 && chatID != allowedChatID {
sendMsg(bot, chatID, "Unauthorized access.")
continue
}
if allowedChatID == 0 {
sendMsg(bot, chatID, "Please send /start to bind your account first.")
continue
}
if text == "" {
continue
}
// Send a placeholder immediately, then stream-edit as reply arrives.
go func(chatID int64, text string) {
// Send ⏳ placeholder so the user sees an instant response.
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
placeholderID := 0
if err == nil {
placeholderID = sent.MessageID
}
// Rate-limited edit helper: edits the placeholder at most once per second.
// Exception: "⏳" thinking-indicator resets always go through immediately
// so the user always sees the state change between agent iterations.
var (
mu sync.Mutex
lastEdit time.Time
)
onChunk := func(accumulated string) {
if placeholderID == 0 {
return
}
mu.Lock()
defer mu.Unlock()
isThinking := accumulated == "⏳"
if !isThinking && time.Since(lastEdit) < time.Second {
return
}
lastEdit = time.Now()
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, accumulated)
bot.Send(edit) //nolint:errcheck
}
reply := agents.Run(chatID, text, onChunk)
// Final edit: use Markdown, fall back to plain text on parse error.
if placeholderID != 0 {
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
edit.ParseMode = "Markdown"
if _, err := bot.Send(edit); err != nil {
edit2 := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
bot.Send(edit2) //nolint:errcheck
}
} else {
msg := tgbotapi.NewMessage(chatID, reply)
msg.ParseMode = "Markdown"
if _, err := bot.Send(msg); err != nil {
msg.ParseMode = ""
bot.Send(msg) //nolint:errcheck
}
}
}(chatID, text)
}
// updates channel was closed — bot stopped cleanly
return true
}
func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
bot.Send(msg) //nolint:errcheck
}
// newLLMClient builds an LLM client for the agent.
// Priority: DB-configured model (Web UI) > environment variables.
// Uses provider-specific constructors to ensure correct default base URLs and models.
func newLLMClient(st *store.Store) mcp.AIClient {
// 1. Try any enabled model from DB (user configured via Web UI, any user_id)
if model, err := st.AIModel().GetAnyEnabled(); err == nil {
apiKey := string(model.APIKey)
if apiKey != "" {
client := clientForProvider(model.Provider)
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
logger.Infof("Telegram agent: provider=%s user_id=%s model=%q url=%q",
model.Provider, model.UserID, model.CustomModelName, model.CustomAPIURL)
return client
}
logger.Warnf("Telegram: DB model found (provider=%s) but API key is empty after decryption", model.Provider)
} else {
logger.Warnf("Telegram: no enabled model in DB (%v), trying env vars", err)
}
// 2. Fall back to environment variables
for _, pair := range []struct{ provider, key, url string }{
{"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL},
{"openai", os.Getenv("OPENAI_API_KEY"), ""},
{"claude", os.Getenv("ANTHROPIC_API_KEY"), ""},
} {
if pair.key != "" {
client := clientForProvider(pair.provider)
client.SetAPIKey(pair.key, pair.url, "")
logger.Infof("Telegram agent: using %s from env var", pair.provider)
return client
}
}
logger.Warn("Telegram: no AI key found in DB or env — agent will fail. Configure a model in the Web UI.")
return mcp.NewDeepSeekClient() // return a typed client so caller gets a clear API error
}
// clientForProvider returns the appropriate provider-specific client.
// Each constructor sets correct default base URL and model for that provider.
func clientForProvider(provider string) mcp.AIClient {
switch provider {
case "openai":
return mcp.NewOpenAIClient()
case "deepseek":
return mcp.NewDeepSeekClient()
default:
// Qwen, Kimi, Grok, Gemini, Claude, custom: fall back to DeepSeek-format client.
// These providers use OpenAI-compatible APIs; CustomAPIURL and CustomModelName are required.
return mcp.NewDeepSeekClient()
}
}
func welcomeMessage() string {
return `*NOFX Trading Assistant Connected!*
You can manage your trading system with natural language:
*Query*
- Show current positions
- Show account balance
*Control*
- Start trader
- Stop trader
*Configure*
- Create a BTC strategy with 8% stop loss
- Configure Binance exchange API
- Add DeepSeek AI model
- Update strategy prompt
Send /help for detailed help
Send /start to reset session`
}
func helpMessage() string {
return `*NOFX Trading Assistant Guide*
*Query examples:*
- "Show current positions"
- "Show account balance"
- "List my traders"
*Control examples:*
- "Start trader"
- "Stop trader [name]"
*Configure examples:*
- "Create a BTC strategy with RSI+MACD, 8% stop loss, 20% max position"
- "Configure Binance exchange, API Key is xxx, Secret is xxx"
- "Add DeepSeek model, Key is xxx"
- "Update strategy prompt for my main strategy to: you are a conservative trader..."
*Other commands:*
- /start - Reset current session
- /help - Show this help
You can use natural language — no need to memorize specific command formats.`
}

105
telegram/session/memory.go Normal file
View File

@@ -0,0 +1,105 @@
package session
import (
"fmt"
"nofx/mcp"
"strings"
)
const (
compactionThresholdTokens = 3000
charsPerToken = 3 // rough estimate for token counting
)
type Message struct {
Role string // "user" or "assistant"
Content string
}
// Memory manages conversation history with automatic compaction.
// Inspired by openclaw's compaction pattern:
// when ShortTerm exceeds threshold, LLM silently summarizes it into LongTerm.
type Memory struct {
LongTerm string // Durable summary (survives compaction, user never sees this happen)
ShortTerm []Message // Recent conversation (cleared on compaction)
llm mcp.AIClient
}
func NewMemory(llm mcp.AIClient) *Memory {
return &Memory{llm: llm}
}
// Add appends a message and triggers compaction if threshold exceeded
func (m *Memory) Add(role, content string) {
m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content})
if m.estimateTokens() > compactionThresholdTokens {
m.compact()
}
}
// BuildContext returns context string for the agent's conversation history.
func (m *Memory) BuildContext() string {
var sb strings.Builder
if m.LongTerm != "" {
sb.WriteString("[Summary of earlier conversation]\n")
sb.WriteString(m.LongTerm)
sb.WriteString("\n\n")
}
if len(m.ShortTerm) > 0 {
sb.WriteString("[Recent conversation]\n")
for _, msg := range m.ShortTerm {
sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content))
}
}
return sb.String()
}
// Reset clears short-term history (LongTerm preserved intentionally)
func (m *Memory) Reset() {
m.ShortTerm = []Message{}
}
// ResetFull clears everything including long-term memory
func (m *Memory) ResetFull() {
m.ShortTerm = []Message{}
m.LongTerm = ""
}
func (m *Memory) estimateTokens() int {
total := len(m.LongTerm)
for _, msg := range m.ShortTerm {
total += len(msg.Content)
}
return total / charsPerToken
}
// compact summarizes short-term history into long-term memory.
// This runs silently - the user never sees it happen.
// If LLM call fails, short-term is preserved as-is (no data loss).
func (m *Memory) compact() {
if m.llm == nil || len(m.ShortTerm) == 0 {
return
}
history := m.BuildContext()
systemPrompt := `You are a conversation summarizer. Compress the following trading assistant conversation into a concise summary.
Must preserve:
- What the user is configuring (strategy/exchange/model/trader)
- Confirmed parameters (trading pairs, leverage, stop loss, indicators, etc.)
- Pending or missing parameters
- User preferences and requirements
Output: plain text summary, under 200 words.`
summary, err := m.llm.CallWithMessages(systemPrompt, history)
if err != nil {
// Compaction failed: keep short-term as-is, never lose user data
return
}
if m.LongTerm != "" {
m.LongTerm = m.LongTerm + "\n" + summary
} else {
m.LongTerm = summary
}
m.ShortTerm = []Message{}
}

View File

@@ -16,6 +16,7 @@ import { getModelIcon } from './ModelIcons'
import { TraderConfigModal } from './TraderConfigModal'
import { DeepVoidBackground } from './DeepVoidBackground'
import { ExchangeConfigModal } from './traders/ExchangeConfigModal'
import { TelegramConfigModal } from './traders/TelegramConfigModal'
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
import {
Bot,
@@ -31,6 +32,7 @@ import {
ExternalLink,
Copy,
Check,
MessageCircle,
} from 'lucide-react'
import { confirmToast } from '../lib/notify'
import { toast } from 'sonner'
@@ -148,6 +150,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [showEditModal, setShowEditModal] = useState(false)
const [showModelModal, setShowModelModal] = useState(false)
const [showExchangeModal, setShowExchangeModal] = useState(false)
const [showTelegramModal, setShowTelegramModal] = useState(false)
const [editingModel, setEditingModel] = useState<string | null>(null)
const [editingExchange, setEditingExchange] = useState<string | null>(null)
const [editingTrader, setEditingTrader] = useState<any>(null)
@@ -849,6 +852,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</button>
<button
onClick={() => setShowTelegramModal(true)}
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider transition-all border border-sky-900/50 bg-black/20 text-sky-500 hover:text-sky-300 hover:border-sky-700 whitespace-nowrap backdrop-blur-sm"
>
<div className="flex items-center gap-2">
<MessageCircle className="w-3 h-3" />
<span>TELEGRAM_BOT</span>
</div>
</button>
<button
onClick={() => setShowCreateModal(true)}
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
@@ -1379,6 +1392,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
language={language}
/>
)}
{/* Telegram Bot Modal */}
{showTelegramModal && (
<TelegramConfigModal
onClose={() => setShowTelegramModal(false)}
language={language}
/>
)}
</div>
</DeepVoidBackground>
)

View File

@@ -336,9 +336,9 @@ export function TraderConfigModal({
<option value="">{t('noStrategyManual', language)}</option>
{strategies.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{selectedStrategy.name}
{selectedStrategy.is_active ? t('active', language) : ''}
{selectedStrategy.is_default ? t('default', language) : ''}
{strategy.name}
{strategy.is_active ? t('strategyActive', language) : ''}
{strategy.is_default ? t('strategyDefault', language) : ''}
</option>
))}
</select>

View File

@@ -0,0 +1,530 @@
import React, { useState, useEffect } from 'react'
import { Check, ChevronLeft, ExternalLink, MessageCircle, Unlink, ArrowRight } from 'lucide-react'
import { toast } from 'sonner'
import { api } from '../../lib/api'
import type { TelegramConfig, AIModel } from '../../types'
import type { Language } from '../../i18n/translations'
// Step indicator (reused pattern from ExchangeConfigModal)
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
return (
<div className="flex items-center justify-center gap-2 mb-6">
{labels.map((label, index) => (
<React.Fragment key={index}>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
style={{
background: index < currentStep ? '#0ECB81' : index === currentStep ? '#2AABEE' : '#2B3139',
color: index <= currentStep ? '#000' : '#848E9C',
}}
>
{index < currentStep ? <Check className="w-4 h-4" /> : index + 1}
</div>
<span
className="text-xs font-medium hidden sm:block"
style={{ color: index === currentStep ? '#EAECEF' : '#848E9C' }}
>
{label}
</span>
</div>
{index < labels.length - 1 && (
<div
className="w-8 h-0.5 mx-1"
style={{ background: index < currentStep ? '#0ECB81' : '#2B3139' }}
/>
)}
</React.Fragment>
))}
</div>
)
}
interface TelegramConfigModalProps {
onClose: () => void
language: Language
}
export function TelegramConfigModal({ onClose, language }: TelegramConfigModalProps) {
const [step, setStep] = useState(0)
const [token, setToken] = useState('')
const [selectedModelId, setSelectedModelId] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [config, setConfig] = useState<TelegramConfig | null>(null)
const [models, setModels] = useState<AIModel[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isUnbinding, setIsUnbinding] = useState(false)
const zh = language === 'zh'
// Load current config and available models
useEffect(() => {
Promise.all([
api.getTelegramConfig().catch(() => null),
api.getModelConfigs().catch(() => [] as AIModel[]),
]).then(([cfg, allModels]) => {
const enabledModels = allModels.filter((m) => m.enabled)
setModels(enabledModels)
if (cfg) {
setConfig(cfg)
setSelectedModelId(cfg.model_id ?? '')
if (cfg.is_bound) {
setStep(2)
} else if (cfg.token_masked && cfg.token_masked !== '') {
setStep(1)
}
}
}).finally(() => setIsLoading(false))
}, [])
const handleSaveToken = async () => {
if (!token.trim()) return
if (isSaving) return
// Basic format validation: looks like "123456789:ABCdef..."
if (!/^\d+:[A-Za-z0-9_-]{35,}$/.test(token.trim())) {
toast.error(zh ? 'Bot Token 格式不正确,应为 "数字:字母数字串"' : 'Invalid Bot Token format. Expected "numbers:alphanumeric"')
return
}
setIsSaving(true)
try {
await api.updateTelegramConfig(token.trim(), selectedModelId || undefined)
toast.success(zh ? 'Bot Token 已保存,等待绑定' : 'Bot Token saved, waiting for binding')
const updated = await api.getTelegramConfig()
setConfig(updated)
setToken('')
setStep(1)
} catch (err) {
toast.error(zh ? '保存失败,请检查 Token 是否正确' : 'Save failed, please verify the token')
} finally {
setIsSaving(false)
}
}
const handleUnbind = async () => {
if (isUnbinding) return
setIsUnbinding(true)
try {
await api.unbindTelegram()
toast.success(zh ? '已解绑 Telegram 账号' : 'Telegram account unbound')
const updated = await api.getTelegramConfig()
setConfig(updated)
setStep(updated.token_masked ? 1 : 0)
} catch {
toast.error(zh ? '解绑失败' : 'Unbind failed')
} finally {
setIsUnbinding(false)
}
}
const stepLabels = zh
? ['创建 Bot', '绑定账号', '完成']
: ['Create Bot', 'Bind Account', 'Done']
// Model selector shared between steps
const ModelSelector = () => (
<div className="space-y-2">
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{zh ? '选择 AI 模型(可选)' : 'Select AI Model (optional)'}
</label>
{models.length === 0 ? (
<div
className="px-4 py-3 rounded-xl text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#848E9C' }}
>
{zh ? '暂无启用的模型请先在「AI 模型」中配置' : 'No enabled models. Configure one in AI Models first.'}
</div>
) : (
<select
value={selectedModelId}
onChange={(e) => setSelectedModelId(e.target.value)}
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: selectedModelId ? '#EAECEF' : '#848E9C',
}}
>
<option value="">{zh ? '— 自动选择(推荐)' : '— Auto-select (recommended)'}</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.provider}{m.customModelName ? ` · ${m.customModelName}` : ''})
</option>
))}
</select>
)}
<div className="text-xs" style={{ color: '#848E9C' }}>
{zh
? '不选则自动使用已启用的模型'
: 'Leave blank to auto-use any enabled model'}
</div>
</div>
)
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
<div
className="rounded-2xl w-full max-w-lg relative my-8 shadow-2xl"
style={{ background: 'linear-gradient(180deg, #1E2329 0%, #181A20 100%)' }}
>
{/* Header */}
<div className="flex items-center justify-between p-6 pb-2">
<div className="flex items-center gap-3">
{step > 0 && !config?.is_bound && (
<button
type="button"
onClick={() => setStep(step - 1)}
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronLeft className="w-5 h-5" style={{ color: '#848E9C' }} />
</button>
)}
<div className="flex items-center gap-2">
<MessageCircle className="w-6 h-6" style={{ color: '#2AABEE' }} />
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{zh ? 'Telegram Bot 配置' : 'Telegram Bot Setup'}
</h3>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
style={{ color: '#848E9C' }}
>
</button>
</div>
{/* Step Indicator */}
<div className="px-6 pt-4">
<StepIndicator currentStep={step} labels={stepLabels} />
</div>
{/* Content */}
<div className="px-6 pb-6 space-y-5">
{isLoading ? (
<div className="text-center py-8 text-zinc-500 text-sm font-mono">
{zh ? '加载中...' : 'Loading...'}
</div>
) : (
<>
{/* Step 0: Create bot via BotFather */}
{step === 0 && (
<div className="space-y-5">
<div
className="p-4 rounded-xl space-y-3"
style={{ background: 'rgba(42, 171, 238, 0.1)', border: '1px solid rgba(42, 171, 238, 0.3)' }}
>
<div className="flex items-start gap-3">
<span className="text-2xl">🤖</span>
<div>
<div className="font-semibold mb-1" style={{ color: '#2AABEE' }}>
{zh ? '第一步:在 Telegram 创建你的 Bot' : 'Step 1: Create your Bot in Telegram'}
</div>
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
<div>1. {zh ? '打开 Telegram搜索' : 'Open Telegram, search for'} <code className="text-blue-400">@BotFather</code></div>
<div>2. {zh ? '发送' : 'Send'} <code className="text-blue-400">/newbot</code> {zh ? '命令' : 'command'}</div>
<div>3. {zh ? '按提示输入 Bot 名称和用户名' : 'Follow prompts to set bot name and username'}</div>
<div>4. {zh ? 'BotFather 会返回一个 Token复制它' : 'BotFather will return a Token, copy it'}</div>
</div>
</div>
</div>
</div>
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold transition-all hover:scale-[1.02]"
style={{ background: '#2AABEE', color: '#000' }}
>
<ExternalLink className="w-4 h-4" />
{zh ? '打开 @BotFather' : 'Open @BotFather'}
</a>
<div className="space-y-2">
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{zh ? '粘贴 Bot Token' : 'Paste Bot Token'}
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="123456789:ABCdefGHIjklmNOPQRstuvwxYZ"
className="w-full px-4 py-3 rounded-xl font-mono text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{zh ? 'Token 格式:数字:字母数字串,如 123456789:ABCdef...' : 'Format: numbers:alphanumeric, e.g. 123456789:ABCdef...'}
</div>
</div>
<ModelSelector />
<button
onClick={handleSaveToken}
disabled={isSaving || !token.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#2AABEE', color: '#000' }}
>
{isSaving
? (zh ? '保存中...' : 'Saving...')
: (<>{zh ? '保存并继续' : 'Save & Continue'} <ArrowRight className="w-4 h-4" /></>)
}
</button>
</div>
)}
{/* Step 1: Send /start to activate */}
{step === 1 && (
<div className="space-y-5">
<div
className="p-4 rounded-xl space-y-3"
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)' }}
>
<div className="flex items-start gap-3">
<span className="text-2xl">📱</span>
<div>
<div className="font-semibold mb-1" style={{ color: '#0ECB81' }}>
{zh ? '第二步:向你的 Bot 发送 /start' : 'Step 2: Send /start to your Bot'}
</div>
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
<div>1. {zh ? '在 Telegram 中搜索你刚创建的 Bot' : 'Search for your newly created Bot in Telegram'}</div>
<div>2. {zh ? '点击 Start 或发送' : 'Click Start or send'} <code className="text-green-400">/start</code></div>
<div>3. {zh ? 'Bot 会自动绑定到你的账号' : 'Bot will automatically bind to your account'}</div>
</div>
</div>
</div>
</div>
{config?.token_masked && (
<div
className="p-3 rounded-xl flex items-center gap-3"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse flex-shrink-0" />
<div>
<div className="text-xs font-mono" style={{ color: '#848E9C' }}>
{zh ? '当前 Token' : 'Current Token'}
</div>
<div className="text-sm font-mono" style={{ color: '#EAECEF' }}>
{config.token_masked}
</div>
</div>
</div>
)}
<div
className="p-3 rounded-xl text-center"
style={{ background: 'rgba(240, 185, 11, 0.08)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
>
<div className="text-xs" style={{ color: '#F0B90B' }}>
{zh
? '⏳ 等待你发送 /start... 发送后刷新页面查看状态'
: '⏳ Waiting for you to send /start... Refresh page after sending'}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => { setStep(0); setToken('') }}
className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{zh ? '重新配置 Token' : 'Reconfigure Token'}
</button>
<button
onClick={async () => {
try {
const updated = await api.getTelegramConfig()
setConfig(updated)
if (updated.is_bound) {
setStep(2)
toast.success(zh ? '绑定成功!' : 'Bound successfully!')
} else {
toast.info(zh ? '尚未收到 /start请先向 Bot 发送 /start' : 'No /start received yet. Please send /start to your Bot first')
}
} catch {
toast.error(zh ? '检查失败' : 'Check failed')
}
}}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02]"
style={{ background: '#0ECB81', color: '#000' }}
>
<Check className="w-4 h-4" />
{zh ? '检查绑定状态' : 'Check Status'}
</button>
</div>
</div>
)}
{/* Step 2: Bound & active */}
{step === 2 && (
<div className="space-y-5">
<div
className="p-5 rounded-xl text-center space-y-3"
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)' }}
>
<div className="text-4xl">🎉</div>
<div className="font-bold text-lg" style={{ color: '#0ECB81' }}>
{zh ? 'Telegram Bot 已绑定!' : 'Telegram Bot is Active!'}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{zh
? '你现在可以通过 Telegram 用自然语言控制交易系统'
: 'You can now control the trading system via natural language in Telegram'}
</div>
</div>
{config?.token_masked && (
<div
className="p-3 rounded-xl flex items-center gap-3"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<div className="min-w-0">
<div className="text-xs font-mono" style={{ color: '#848E9C' }}>
{zh ? 'Bot Token' : 'Bot Token'}
</div>
<div className="text-sm font-mono truncate" style={{ color: '#EAECEF' }}>
{config.token_masked}
</div>
</div>
</div>
)}
{/* AI Model selector — works on active bot */}
<BoundModelSelector
zh={zh}
models={models}
currentModelId={config?.model_id ?? ''}
onSaved={(modelId) => {
setConfig((prev) => prev ? { ...prev, model_id: modelId } : prev)
}}
/>
{/* What you can do */}
<div
className="p-4 rounded-xl space-y-2"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#848E9C' }}>
{zh ? '支持的命令' : 'Supported Commands'}
</div>
{[
{ cmd: '/help', desc: zh ? '查看所有命令' : 'Show all commands' },
{ cmd: zh ? '查看交易员状态' : 'Show trader status', desc: zh ? '自然语言查询' : 'Natural language' },
{ cmd: zh ? '启动/停止交易员' : 'Start/stop trader', desc: zh ? '自然语言控制' : 'Natural language control' },
{ cmd: zh ? '查看持仓' : 'View positions', desc: zh ? '实时持仓查询' : 'Real-time position query' },
{ cmd: zh ? '配置策略' : 'Configure strategy', desc: zh ? '修改交易策略' : 'Modify trading strategy' },
].map((item, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
<code className="font-mono px-1.5 py-0.5 rounded flex-shrink-0" style={{ background: '#1E2329', color: '#2AABEE' }}>
{item.cmd}
</code>
<span style={{ color: '#848E9C' }}>{item.desc}</span>
</div>
))}
</div>
<div className="flex gap-3">
<button
onClick={handleUnbind}
disabled={isUnbinding}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5 disabled:opacity-50"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }}
>
<Unlink className="w-4 h-4" />
{isUnbinding ? (zh ? '解绑中...' : 'Unbinding...') : (zh ? '解绑账号' : 'Unbind Account')}
</button>
<button
onClick={onClose}
className="flex-1 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02]"
style={{ background: '#2AABEE', color: '#000' }}
>
{zh ? '完成' : 'Done'}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
// BoundModelSelector — lets the user change the AI model when the bot is already active.
// It updates the model_id without requiring re-entry of the bot token.
function BoundModelSelector({
zh,
models,
currentModelId,
onSaved,
}: {
zh: boolean
models: AIModel[]
currentModelId: string
onSaved: (modelId: string) => void
}) {
const [modelId, setModelId] = useState(currentModelId)
const [isSaving, setIsSaving] = useState(false)
// Keep in sync if parent updates
useEffect(() => { setModelId(currentModelId) }, [currentModelId])
const handleSave = async () => {
setIsSaving(true)
try {
// POST /api/telegram/model — lightweight endpoint for model-only update
await api.updateTelegramModel(modelId)
onSaved(modelId)
toast.success(zh ? 'AI 模型已更新' : 'AI model updated')
} catch {
toast.error(zh ? '更新失败' : 'Update failed')
} finally {
setIsSaving(false)
}
}
if (models.length === 0) return null
return (
<div className="space-y-2">
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{zh ? 'AI 模型(用于自然语言解析)' : 'AI Model (for natural language)'}
</label>
<div className="flex gap-2">
<select
value={modelId}
onChange={(e) => setModelId(e.target.value)}
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: modelId ? '#EAECEF' : '#848E9C',
}}
>
<option value="">{zh ? '— 自动选择' : '— Auto-select'}</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}{m.customModelName ? ` · ${m.customModelName}` : ''}
</option>
))}
</select>
<button
onClick={handleSave}
disabled={isSaving || modelId === currentModelId}
className="px-4 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: '#F0B90B', color: '#000', whiteSpace: 'nowrap' }}
>
{isSaving ? '...' : (zh ? '保存' : 'Save')}
</button>
</div>
</div>
)
}

View File

@@ -25,9 +25,6 @@ interface AuthContextType {
email: string,
password: string,
betaCode?: string
) => Promise<{
success: boolean
message?: string
) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,

View File

@@ -1,4 +1,4 @@
NOFX i18n Consolidation - Translation Keys
// NOFX i18n Consolidation - Translation Keys
// Generated by Atlas Orchestrator
// Branch: feat/i18n-consolidation-patch
// Purpose: Centralize scattered i18n strings from 8 strategy components

View File

@@ -339,8 +339,8 @@ export const translations = {
selectTradingStrategy: 'Select Trading Strategy',
useStrategy: 'Use Strategy',
noStrategyManual: '-- No Strategy (Manual Configuration) --',
active: ' (Active)',
default: ' [Default]',
strategyActive: ' (Active)',
strategyDefault: ' [Default]',
noStrategyHint: 'No strategies yet, please create in Strategy Studio first',
strategyDetails: 'Strategy Details',
activating: 'Activating',
@@ -1563,8 +1563,8 @@ export const translations = {
selectTradingStrategy: '选择交易策略',
useStrategy: '使用策略',
noStrategyManual: '-- 不使用策略(手动配置) --',
active: ' (当前激活)',
default: ' [默认]',
strategyActive: ' (当前激活)',
strategyDefault: ' [默认]',
noStrategyHint: '暂无策略,请先在策略工作室创建策略',
strategyDetails: '策略详情',
activating: '激活中',
@@ -2734,8 +2734,8 @@ export const translations = {
selectTradingStrategy: 'Pilih Strategi Trading',
useStrategy: 'Gunakan Strategi',
noStrategyManual: '-- Tanpa Strategi (Konfigurasi Manual) --',
active: ' (Aktif)',
default: ' [Default]',
strategyActive: ' (Aktif)',
strategyDefault: ' [Default]',
noStrategyHint: 'Belum ada strategi, buat di Strategy Studio terlebih dahulu',
strategyDetails: 'Detail Strategi',
activating: 'Mengaktifkan',

View File

@@ -8,6 +8,7 @@ import type {
TraderConfigData,
AIModel,
Exchange,
TelegramConfig,
CreateTraderRequest,
CreateExchangeRequest,
UpdateModelConfigRequest,
@@ -785,4 +786,26 @@ export const api = {
if (!result.success) throw new Error('获取历史仓位失败')
return result.data!
},
// Telegram Bot API
async getTelegramConfig(): Promise<TelegramConfig> {
const result = await httpClient.get<TelegramConfig>(`${API_BASE}/telegram`)
if (!result.success) throw new Error('获取Telegram配置失败')
return result.data!
},
async updateTelegramConfig(token: string, modelId?: string): Promise<void> {
const result = await httpClient.post(`${API_BASE}/telegram`, { bot_token: token, model_id: modelId ?? '' })
if (!result.success) throw new Error('保存Telegram配置失败')
},
async unbindTelegram(): Promise<void> {
const result = await httpClient.delete(`${API_BASE}/telegram/binding`)
if (!result.success) throw new Error('解绑Telegram失败')
},
async updateTelegramModel(modelId: string): Promise<void> {
const result = await httpClient.post(`${API_BASE}/telegram/model`, { model_id: modelId })
if (!result.success) throw new Error('更新Telegram模型失败')
},
}

View File

@@ -116,6 +116,13 @@ export interface AIModel {
customModelName?: string
}
export interface TelegramConfig {
token_masked: string // Masked token like "123456:ABC***XYZ"
is_bound: boolean // Whether a user has sent /start
bound_chat_id?: number // The bound chat ID (if any)
model_id?: string // AI model selected for Telegram replies
}
export interface Exchange {
id: string // UUID (empty for supported exchange templates)
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"