diff --git a/.gitignore b/.gitignore index 7a2f6321..1f3eeb12 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ nofx_test # Go 相关 *.test *.out +.gocache/ # 操作系统 .DS_Store diff --git a/api/route_registry.go b/api/route_registry.go new file mode 100644 index 00000000..23bdef8d --- /dev/null +++ b/api/route_registry.go @@ -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() +} diff --git a/api/server.go b/api/server.go index fc545a54..211d5aff 100644 --- a/api/server.go +++ b/api/server.go @@ -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":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":} +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":""}`, + 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":""}`, + 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":{"":{"enabled":,"api_key":"","custom_api_url":"","custom_model_name":""}}} +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":"","account_name":"","enabled":true,"api_key":"","secret_key":"","passphrase":""} +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":"","description":"","lang":"zh|en","config":} +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":"","description":"","config":} +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}) +} diff --git a/api/strategy.go b/api/strategy.go index 5f724ab6..03484c55 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -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 { diff --git a/config/config.go b/config/config.go index 1a4a0d96..91127d22 100644 --- a/config/config.go +++ b/config/config.go @@ -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) diff --git a/docs/plans/2026-03-06-telegram-agent-redesign.md b/docs/plans/2026-03-06-telegram-agent-redesign.md new file mode 100644 index 00000000..9764d985 --- /dev/null +++ b/docs/plans/2026-03-06-telegram-agent-redesign.md @@ -0,0 +1,1039 @@ +# Telegram Bot Agent Redesign (OpenClaw-Inspired) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Replace the NLU intent-classification architecture with a true AI Agent that handles any user request — including scenarios never explicitly programmed. All code, comments, prompts, and bot responses in English. + +**Architecture:** One generic tool (`api_call`) + dynamically generated API docs + unbounded LLM loop. The LLM reads auto-generated API docs and decides which endpoints to call. New features added to the web UI automatically become available via bot — zero code changes required. + +**Tech Stack:** Go, `mcp.CallWithRequest` + `RequestBuilder`, `tgbotapi`, `auth.GenerateJWT` + +--- + +## Core Design + +OpenClaw gives LLM a `bash` tool — one generic primitive, unlimited capability. +We give LLM an `api_call(method, path, body)` tool — one generic primitive for 74+ REST endpoints. + +**Auto-discovery:** Routes are registered via `s.route(group, method, path, description, handler)`. +`api.GetAPIDocs()` returns live documentation at startup — add a route and it's automatically in the bot's context. + +``` +User: "show positions and stop the trader if loss > 5%" + +Iteration 1: api_call GET /api/positions?trader_id=... +Iteration 2: api_call GET /api/account?trader_id=... +Iteration 3: [sees -8% loss] api_call POST /api/traders/xxx/stop +Reply: "Detected -8% loss. Trader stopped." +``` + +No special code for this scenario. LLM figured it out from the API docs. + +--- + +## What changes + +| File | Action | +|------|--------| +| `api/route_registry.go` | **CREATE** — route registration + doc generation | +| `api/server.go` | Migrate all routes from `group.METHOD(path, handler)` to `s.route(group, method, path, desc, handler)` | +| `telegram/intent/parser.go` | **DELETE** | +| `telegram/handler/handler.go` | **DELETE** | +| `telegram/handler/handler_test.go` | **DELETE** | +| `telegram/session/session.go` | Simplify (remove Intent, Params) | +| `telegram/bot.go` | Use `agent.Manager`, pass `api.GetAPIDocs()` | +| `telegram/agent/prompt.go` | **CREATE** — system prompt template (API docs injected at runtime) | +| `telegram/agent/apicall.go` | **CREATE** — the single generic tool | +| `telegram/agent/agent.go` | **CREATE** — agent loop | +| `telegram/agent/manager.go` | **CREATE** — per-chat serialization | +| `telegram/agent/agent_test.go` | **CREATE** — tests | + +`telegram/service/nofx.go` and `telegram/session/memory.go` are **unchanged**. + +--- + +## Task 1: Create `api/route_registry.go` + +**Files:** +- Create: `api/route_registry.go` + +This is the single source of truth for API documentation. Routes registered here are automatically available to the bot. + +```go +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 +} + +// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes. +var routeRegistry []RouteDoc + +// route registers an HTTP route on the given group and records its documentation. +// This is the single registration point — add a route here and it is automatically +// included in GetAPIDocs(), making it available to the Telegram bot agent. +func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) { + // Derive the full path: group prefix + local path + fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/") + routeRegistry = append(routeRegistry, RouteDoc{ + Method: method, + Path: fullPath, + Description: description, + }) + 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. +// Called once at bot startup — reflects the live set of registered routes. +func GetAPIDocs() string { + var sb strings.Builder + for _, r := range routeRegistry { + sb.WriteString(fmt.Sprintf("%-8s %-50s %s\n", r.Method, r.Path, r.Description)) + } + return sb.String() +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./api/... +``` + +Expected: clean build. + +**Step 3: Commit** + +```bash +git add api/route_registry.go +git commit -m "feat(api): add route registry for auto-generated API documentation" +``` + +--- + +## Task 2: Migrate routes in `api/server.go` + +**Files:** +- Modify: `api/server.go` (the `setupRoutes` / route registration block, lines ~109–230) + +Replace every direct `group.METHOD(path, handler)` call with `s.route(group, method, path, description, handler)`. + +**Step 1: Read the current route registration block** + +```bash +sed -n '109,230p' api/server.go +``` + +**Step 2: Replace all route registrations** + +The full replacement (covers all routes found in lines 117–223): + +```go +// Public routes +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) +s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig) +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) +s.route(api, "GET", "/klines", "Candlestick data (?symbol=&interval=&limit=)", s.handleKlines) +s.route(api, "GET", "/symbols", "Available trading symbols", s.handleSymbols) +s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies) +s.route(api, "POST", "/register", "Register new user", s.handleRegister) +s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) + +// Protected routes (JWT required) +s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout) +s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP) + +// Trader management +s.route(protected, "GET", "/my-traders", "List user's traders", s.handleTraderList) +s.route(protected, "GET", "/traders/:id/config", "Get full trader configuration", s.handleGetTraderConfig) +s.route(protected, "POST", "/traders", "Create trader (body: name, strategy_id, exchange_id, model_id)", 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", s.handleStartTrader) +s.route(protected, "POST", "/traders/:id/stop", "Stop trader", s.handleStopTrader) +s.route(protected, "PUT", "/traders/:id/prompt", "Update trader prompt (body: prompt)", s.handleUpdateTraderPrompt) +s.route(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", s.handleSyncBalance) +s.route(protected, "POST", "/traders/:id/close-position", "Close position (body: symbol)", s.handleClosePosition) +s.route(protected, "PUT", "/traders/:id/competition", "Toggle competition visibility", s.handleToggleCompetition) +s.route(protected, "GET", "/traders/:id/grid-risk", "Get grid risk info", s.handleGetGridRiskInfo) + +// AI model configuration +s.route(protected, "GET", "/models", "List AI model configurations", s.handleGetModelConfigs) +s.route(protected, "PUT", "/models", "Update AI model configurations", s.handleUpdateModelConfigs) + +// Exchange configuration +s.route(protected, "GET", "/exchanges", "List exchange configurations", s.handleGetExchangeConfigs) +s.route(protected, "POST", "/exchanges", "Create exchange (body: exchange_type, api_key, secret_key, account_name)", s.handleCreateExchange) +s.route(protected, "PUT", "/exchanges", "Update exchange configurations", s.handleUpdateExchangeConfigs) +s.route(protected, "DELETE", "/exchanges/:id", "Delete exchange", s.handleDeleteExchange) + +// Telegram 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 +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 template", s.handleGetDefaultStrategyConfig) +s.route(protected, "POST", "/strategies/preview-prompt", "Preview generated strategy prompt", 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.route(protected, "POST", "/strategies", "Create strategy (body: name, config)", s.handleCreateStrategy) +s.route(protected, "PUT", "/strategies/:id", "Update strategy", s.handleUpdateStrategy) +s.route(protected, "DELETE", "/strategies/:id", "Delete strategy", s.handleDeleteStrategy) +s.route(protected, "POST", "/strategies/:id/activate", "Activate strategy", s.handleActivateStrategy) +s.route(protected, "POST", "/strategies/:id/duplicate", "Duplicate strategy", s.handleDuplicateStrategy) + +// Debate arena +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) + +// Account and trading data (use ?trader_id=xxx query param) +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) +``` + +Note: keep the existing special-case handlers that don't use `s.route` unchanged: +- `api.Any("/health", ...)` — health check, no need to document +- `api.GET("/crypto/...")` — crypto/encryption routes, bot doesn't need these +- `backtest.*` routes (registered separately) — add descriptions to the backtest group similarly + +**Step 3: Build** + +```bash +go build ./api/... +``` + +Expected: clean build. Fix any compilation errors (method signature mismatches). + +**Step 4: Verify docs are generated** + +```bash +go test ./api/... -run TestGetAPIDocs -v +``` + +(Write a quick inline test or just print in main to verify) + +**Step 5: Commit** + +```bash +git add api/route_registry.go api/server.go +git commit -m "feat(api): migrate routes to self-documenting s.route() registration" +``` + +--- + +## Task 3: Create `telegram/agent/prompt.go` + +**Files:** +- Create: `telegram/agent/prompt.go` + +The system prompt template. API docs are injected at runtime via `BuildAgentPrompt(apiDocs)`. + +```go +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. +func BuildAgentPrompt(apiDocs string) string { + return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant. +You can have natural conversations with the user and call the API to operate the system. + +## Tool + +You have one tool: api_call + +Call format (append at end of reply): +{"method":"GET","path":"/api/xxx","body":{}} + +- method: "GET" | "POST" | "PUT" | "DELETE" +- path: API path from the documentation below +- body: request body as JSON object (use {} for GET requests) +- query parameters go in the path, e.g. /api/positions?trader_id=xxx + +## NOFX API Documentation + +All requests are pre-authenticated. Focus on paths and parameters. + +%s + +## Rules +1. When you need to perform a system operation, append ... at the end of your reply +2. Only call one API per response; after receiving the result, decide whether to call another or give a final reply +3. For conversations, questions, or analysis that don't require system operations, reply directly without calling the API +4. If required parameters are unclear, ask the user — do not guess critical values like trader_id +5. Always reply in English`, apiDocs) +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +go build ./telegram/agent/... +``` + +**Step 3: Commit** + +```bash +git add telegram/agent/prompt.go +git commit -m "feat(telegram/agent): add dynamic system prompt builder" +``` + +--- + +## Task 4: Create `telegram/agent/apicall.go` + +**Files:** +- Create: `telegram/agent/apicall.go` + +```go +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 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 ... from LLM response. +// Returns (nil, original) if not found or malformed JSON. +func parseAPICall(resp string) (*apiRequest, string) { + const openTag = "" + const closeTag = "" + + 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]) +} +``` + +**Step 1: Create the file** + +**Step 2: Commit** + +```bash +git add telegram/agent/apicall.go +git commit -m "feat(telegram/agent): add generic api_call tool" +``` + +--- + +## Task 5: Create `telegram/agent/agent.go` + +**Files:** +- Create: `telegram/agent/agent.go` + +```go +package agent + +import ( + "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 +} + +// New creates an Agent for one chat session. +func New(apiPort int, botToken string, getLLM func() mcp.AIClient, systemPrompt string) *Agent { + return &Agent{ + apiTool: newAPICallTool(apiPort, botToken), + getLLM: getLLM, + memory: session.NewMemory(getLLM()), + systemPrompt: systemPrompt, + } +} + +// GenerateBotToken creates a long-lived JWT for the bot's internal API calls. +// Call once at bot startup before creating any Agent or Manager. +func GenerateBotToken() (string, error) { + return auth.GenerateJWT("default", "bot@internal") +} + +// Run processes one user message through the agent loop. +// Loop: LLM decides -> if : execute, append result, loop -> if no tag: return reply. +func (a *Agent) Run(userMessage 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 + histCtx := a.memory.BuildContext() + firstMsg := userMessage + if histCtx != "" { + 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 + } + + 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 + reply := strings.TrimSpace(resp) + a.memory.Add("user", userMessage) + a.memory.Add("assistant", reply) + return reply + } + + 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 + 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 := strings.TrimSpace(lastResp) + a.memory.Add("user", userMessage) + a.memory.Add("assistant", reply) + return reply +} + +// ResetMemory clears conversation history (called on /start). +func (a *Agent) ResetMemory() { + a.memory.ResetFull() +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +go build ./telegram/agent/... +``` + +**Step 3: Commit** + +```bash +git add telegram/agent/agent.go +git commit -m "feat(telegram/agent): add OpenClaw-style agent loop" +``` + +--- + +## Task 6: Create `telegram/agent/manager.go` + +**Files:** +- Create: `telegram/agent/manager.go` + +```go +package agent + +import ( + "nofx/mcp" + "sync" +) + +// 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 + getLLM func() mcp.AIClient + systemPrompt string +} + +// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs. +func NewManager(apiPort int, botToken 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, + getLLM: getLLM, + systemPrompt: BuildAgentPrompt(apiDocs), + } +} + +// Run processes a message for the given chat ID. +// If the same chat is already processing a message, this call blocks until it completes. +func (m *Manager) Run(chatID int64, userMessage string) string { + a, lane := m.getOrCreate(chatID) + lane <- struct{}{} + defer func() { <-lane }() + return a.Run(userMessage) +} + +// 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.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 +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +go build ./telegram/agent/... +``` + +**Step 3: Commit** + +```bash +git add telegram/agent/manager.go +git commit -m "feat(telegram/agent): add per-chat agent manager with lane serialization" +``` + +--- + +## Task 7: Write tests + +**Files:** +- Create: `telegram/agent/agent_test.go` + +```go +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) 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", mockGetLLM(llm), testPrompt) + + reply := a.Run("hello") + + 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"}]`)) + 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.{"method":"GET","path":"/api/my-traders","body":{}}`, + "You have one trader: BTC Strategy.", + }} + a := New(port, "tok", mockGetLLM(llm), testPrompt) + + reply := a.Run("list my traders") + + 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}`)) + })) + defer srv.Close() + + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + + llm := &mockLLM{responses: []string{ + `Checking account.{"method":"GET","path":"/api/account","body":{}}`, + `Now checking positions.{"method":"GET","path":"/api/positions","body":{}}`, + "Account looks healthy and no open positions.", + }} + a := New(port, "tok", mockGetLLM(llm), testPrompt) + + reply := a.Run("show me account status") + + 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}`)) + })) + defer srv.Close() + + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + + llm := &mockLLM{responses: []string{ + `{"method":"GET","path":"/api/account","body":{}}`, + "Balance is 1234.56 USDT.", + }} + a := New(port, "tok", mockGetLLM(llm), testPrompt) + a.Run("show balance") + + 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.{"method":"POST","path":"/api/traders/t1/stop","body":{}}` + 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(`NOT JSON`) + if req != nil { + t.Fatal("expected nil for malformed JSON") + } + }) +} +``` + +**Step 1: Create the test file** + +**Step 2: Run tests** + +```bash +go test ./telegram/agent/... -v +``` + +Expected: all PASS. + +**Step 3: Commit** + +```bash +git add telegram/agent/agent_test.go +git commit -m "test(telegram/agent): add agent tests with mock HTTP server" +``` + +--- + +## Task 8: Simplify `telegram/session/session.go` + +Replace file content: + +```go +package session + +import ( + "nofx/mcp" + "sync" + "time" +) + +// Session holds conversation memory for a single Telegram chat. +type Session struct { + ChatID int64 + Memory *Memory + UpdatedAt time.Time +} + +func (s *Session) ResetFull() { s.Memory.ResetFull() } + +// Manager manages sessions by chat ID. +type Manager struct { + mu sync.RWMutex + sessions map[int64]*Session + llm mcp.AIClient +} + +func NewManager(llm mcp.AIClient) *Manager { + return &Manager{sessions: make(map[int64]*Session), llm: llm} +} + +func (m *Manager) Get(chatID int64) *Session { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sessions[chatID] + if !ok { + s = &Session{ChatID: chatID, Memory: NewMemory(m.llm), UpdatedAt: time.Now()} + m.sessions[chatID] = s + } + s.UpdatedAt = time.Now() + return s +} +``` + +```bash +go build ./... +git add telegram/session/session.go +git commit -m "refactor(telegram/session): remove intent/params fields" +``` + +--- + +## Task 9: Wire `telegram/bot.go` + +**Step 1: In `runBot`, replace old wiring with:** + +```go +botToken, err := agent.GenerateBotToken() +if err != nil { + logger.Errorf("Failed to generate bot JWT: %v", err) + return false +} +agents := agent.NewManager(cfg.APIServerPort, botToken, + func() mcp.AIClient { return newLLMClient(st) }, + api.GetAPIDocs(), +) +``` + +**Step 2: Replace `/start` reset:** +```go +// old: sessions.Get(chatID).ResetFull() +agents.Reset(chatID) +``` + +**Step 3: Replace message processing:** +```go +go func(chatID int64, text string) { + bot.Send(tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)) //nolint:errcheck + reply := agents.Run(chatID, text) + msg := tgbotapi.NewMessage(chatID, reply) + msg.ParseMode = "Markdown" + if _, err := bot.Send(msg); err != nil { + msg.ParseMode = "" + bot.Send(msg) //nolint:errcheck + } +}(chatID, text) +``` + +**Step 4: Update imports** — remove `service`, `handler`, `intent`, `session`; add `agent`, `api`: + +```go +import ( + "nofx/config" + "nofx/logger" + "nofx/manager" + "nofx/mcp" + "nofx/store" + "nofx/api" + "nofx/telegram/agent" + "os" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) +``` + +**Step 5: Full build** + +```bash +go build ./... +git add telegram/bot.go +git commit -m "feat(telegram): wire agent.Manager with auto-generated API docs" +``` + +--- + +## Task 10: Delete old files + +```bash +git rm telegram/intent/parser.go telegram/handler/handler.go telegram/handler/handler_test.go +rmdir telegram/intent telegram/handler 2>/dev/null || true +go build ./... && go test ./... +git commit -m "refactor(telegram): delete old intent/handler packages" +``` + +--- + +## Task 11: End-to-end verification + +```bash +go test ./telegram/... ./api/... -v -count=1 +go build ./... +``` + +Manual verification — none of these scenarios need any special code: +- [ ] "hello" → natural conversation reply +- [ ] "list my traders" → GET /api/my-traders, formatted reply +- [ ] "show positions" → GET /api/positions +- [ ] "check balance then stop trader if loss > 5%" → multi-step: GET /api/account → POST /api/traders/:id/stop +- [ ] "create a BTC strategy with 5% stop loss" → GET /api/strategies/default-config → POST /api/strategies +- [ ] "show latest trading decisions" → GET /api/decisions/latest +- [ ] "what's the BTC 1h chart looking like" → GET /api/klines?symbol=BTCUSDT&interval=1h +- [ ] "delete trader xxx" → DELETE /api/traders/:id +- [ ] Any unrecognized input → LLM replies naturally, no error diff --git a/docs/plans/2026-03-06-telegram-bot.md b/docs/plans/2026-03-06-telegram-bot.md new file mode 100644 index 00000000..1627dce5 --- /dev/null +++ b/docs/plans/2026-03-06-telegram-bot.md @@ -0,0 +1,1218 @@ +# Telegram Bot Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在 NOFX 单进程内内置 Telegram Bot,用户通过自然语言(LLM 解析意图)在 Telegram 配置策略、交易所、大模型、交易员、查询持仓、控制交易。 + +**Architecture:** 新增 `telegram/` 包,单一 Facade 层(`service/nofx.go`)作为唯一接触 NOFX 内部的边界,借鉴 openclaw compaction 模式实现多轮对话记忆压缩,`main.go` 仅增加 3 行。 + +**Tech Stack:** Go, `github.com/go-telegram-bot-api/telegram-bot-api/v5`(已在 go.mod), `nofx/mcp`(复用现有 LLM 客户端) + +--- + +## 监工修正(Claude 开始前先读) + +这份文档里的代码块只能当伪代码参考,**不能直接照抄**。当前仓库真实接口和文档示例存在多处偏差,首轮实现必须以编译通过的仓库接口为准。 + +### 真实接口约束 + +1. `manager.TraderManager` **没有** `StartTrader` / `StopTrader` 方法。 + - Telegram 启停交易员时,必须复用现有 API Server 的流程语义: + - 启动:校验归属 -> 移除已停止的内存实例 -> `LoadUserTradersFromStore()` -> `GetTrader()` -> `go trader.Run()` -> `store.Trader().UpdateStatus(userID, traderID, true)` + - 停止:`GetTrader()` -> 检查 `GetStatus()["is_running"]` -> `Stop()` -> `UpdateStatus(..., false)` + +2. `store` 方法签名与文档示例不一致,必须按真实接口实现: + - `store.Trader().List(userID)` 返回 `[]*store.Trader` + - `store.Trader()` 没有 `Get(traderID)`,常用的是 `GetFullConfig(userID, traderID)` + - `store.Strategy().Get(userID, id string)`,`Strategy.ID` 是 `string`,不是 `uint` + - `store.AIModel().Create(...)` 返回 `error`,不是 `*store.AIModel` + - `store.Exchange().Create(...)` 返回 `(string, error)`,不是 `*store.Exchange` + - `store.Exchange()` 读单条配置用 `GetByID(userID, id)` + - `store.Equity()` 没有 `Latest`,现有方法是 `GetLatest(traderID, limit)` + - `store.Position()` 没有 `ListByTrader` + +3. `mcp.New()` 在当前仓库中不存在。 + - 必须使用已有构造器,例如 `mcp.NewDeepSeekClient()`、`mcp.NewClient(...)`,或新增一个显式 helper。 + +4. 策略创建不能直接拼一个“猜测字段”的 JSON。 + - 当前真实结构是 `store.StrategyConfig` + - 首选做法:从 `store.GetDefaultStrategyConfig("zh")` 起步,修改需要的字段,再 `json.Marshal` + - `Strategy.ID` 需要像现有 API 一样使用 `uuid.New().String()` + +5. “修改策略 Prompt” 不能按文档示例那样直接改 `Strategy.CustomPrompt`。 + - `store.Strategy` 没有这个顶层字段 + - 真实做法应是:读取 `strategy.Config` -> `ParseConfig()` -> 更新 `StrategyConfig.CustomPrompt` 或相关 prompt section -> 序列化回 `strategy.Config` -> `Update(strategy)` + +6. `/start` 的“完全重置”与当前伪代码冲突。 + - 现在 `Memory.Reset()` 只清空短期历史,不清空长期摘要 + - 如果 `/start` 要“重置会话”,就必须新增 `ClearAll()` 或重建 `Memory` + +7. 不要在 Telegram 回复里默认启用 `Markdown` parse mode。 + - 用户输入、策略名、API key、交易对等都可能包含 Markdown 特殊字符 + - 首版建议纯文本回复,稳定后再做 escape + +8. 不要在日志、回复、错误信息中回显敏感字段。 + - `api_key` + - `secret_key` + - `passphrase` + - 私钥或钱包密钥 + +### 首轮交付范围(必须收敛) + +首个可交付版本只做“最小可用闭环”,不要一口气把所有写操作做满: + +1. 必做: + - Telegram Bot 启动 + - 管理员 chat ID 鉴权 + - `/start` 重置会话 + - 会话管理 + - LLM 意图解析 + - 只读查询:`list traders` / `query positions` / `query equity` + - 控制:`start trader` / `stop trader` + +2. 第二阶段再做: + - `config_strategy` + - `config_exchange` + - `config_model` + - `config_trader` + - `update_prompt` + +3. `control_close` 先不要做,除非先找到仓库里现成且安全的平仓入口。 + +### 硬性门禁 + +1. 每个子任务至少过 `go build ./telegram/...` +2. 合并前必须过 `go build ./...` +3. `handler/` 不允许直接碰 `store/` 或 `manager/` +4. 所有跨层访问都只能从 `telegram/service/nofx.go` 进入 +5. 任何伪代码字段名、方法名、返回值,在落地前都必须先对照真实仓库接口 + +--- + +## 文件结构 + +``` +telegram/ +├── bot.go # 新建:Bot 启动、消息收发路由 +├── session/ +│ ├── session.go # 新建:会话状态(当前意图、进度) +│ └── memory.go # 新建:对话记忆 + 自动压缩 +├── intent/ +│ └── parser.go # 新建:LLM 意图解析 +├── service/ +│ └── nofx.go # 新建:Facade(唯一接触 store/manager 的地方) +└── handler/ + └── handler.go # 新建:业务路由,只调 service/ 和 intent/ + +config/config.go # 修改:加 TelegramBotToken, TelegramAdminChatID +main.go # 修改:加 3 行启动 Telegram Bot +``` + +--- + +### Task 1: 扩展 Config + +**Files:** +- Modify: `config/config.go` + +**Step 1: 在 Config struct 末尾加两个字段** + +```go +// Telegram Bot configuration +TelegramBotToken string // TELEGRAM_BOT_TOKEN +TelegramAdminChatID int64 // TELEGRAM_ADMIN_CHAT_ID (only this user can operate) +``` + +**Step 2: 在 Init() 函数的解析段加读取逻辑** + +找到 Init() 函数中 os.Getenv 的模式,加: + +```go +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 + } +} +``` + +**监工补充:** `Init()` 函数里当前一直在填充局部变量 `cfg`,最后才赋值给 `global`,这里不能提前写 `global.TelegramBotToken` + +**Step 3: 构建验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./... +``` + +Expected: 无错误 + +**Step 4: Commit** + +```bash +git add config/config.go +git commit -m "feat(telegram): add TelegramBotToken and TelegramAdminChatID to config" +``` + +--- + +### Task 2: Facade 层 telegram/service/nofx.go + +**Files:** +- Create: `telegram/service/nofx.go` + +这是**唯一**接触 NOFX 内部(store、manager)的文件。handler 不直接碰 store/manager。 + +**Step 1: 创建文件** + +```go +package service + +import ( + "fmt" + "nofx/manager" + "nofx/store" +) + +// NofxService is the single facade between Telegram bot and NOFX internals. +// All store/manager access MUST go through this layer. +type NofxService struct { + store *store.Store + manager *manager.TraderManager + userID string // fixed user ID for single-user mode: "default" +} + +func New(st *store.Store, tm *manager.TraderManager) *NofxService { + return &NofxService{store: st, manager: tm, userID: "default"} +} + +// --- Trader --- + +func (s *NofxService) ListTraders() ([]store.Trader, error) { + return s.store.Trader().List(s.userID) +} + +func (s *NofxService) StartTrader(traderID string) error { + t, err := s.store.Trader().Get(traderID) + if err != nil { + return fmt.Errorf("trader not found: %w", err) + } + return s.manager.StartTrader(t, s.store) +} + +func (s *NofxService) StopTrader(traderID string) error { + return s.manager.StopTrader(traderID) +} + +// --- Strategy --- + +func (s *NofxService) ListStrategies() ([]store.Strategy, error) { + return s.store.Strategy().List(s.userID) +} + +func (s *NofxService) CreateStrategy(name string, configJSON string) (*store.Strategy, error) { + strategy := &store.Strategy{ + UserID: s.userID, + Name: name, + Config: configJSON, + } + if err := s.store.Strategy().Create(strategy); err != nil { + return nil, err + } + return strategy, nil +} + +func (s *NofxService) UpdateStrategyPrompt(strategyID uint, prompt string) error { + strategy, err := s.store.Strategy().Get(strategyID) + if err != nil { + return err + } + strategy.CustomPrompt = prompt + return s.store.Strategy().Update(strategy) +} + +// --- AI Model --- + +func (s *NofxService) ListModels() ([]store.AIModel, error) { + return s.store.AIModel().List(s.userID) +} + +func (s *NofxService) CreateModel(provider, apiKey, model string) (*store.AIModel, error) { + m := &store.AIModel{ + UserID: s.userID, + Provider: provider, + APIKey: apiKey, + Model: model, + } + if err := s.store.AIModel().Create(m); err != nil { + return nil, err + } + return m, nil +} + +// --- Exchange --- + +func (s *NofxService) ListExchanges() ([]store.Exchange, error) { + return s.store.Exchange().List(s.userID) +} + +func (s *NofxService) CreateExchange(exchangeType, apiKey, secretKey string) (*store.Exchange, error) { + ex := &store.Exchange{ + UserID: s.userID, + ExchangeType: exchangeType, + APIKey: apiKey, + SecretKey: secretKey, + } + if err := s.store.Exchange().Create(ex); err != nil { + return nil, err + } + return ex, nil +} + +// --- Positions / Query --- + +func (s *NofxService) GetPositions(traderID string) ([]store.TraderPosition, error) { + return s.store.Position().ListByTrader(traderID) +} + +func (s *NofxService) GetEquitySummary(traderID string) (*store.EquitySnapshot, error) { + return s.store.Equity().Latest(traderID) +} +``` + +**Step 2: 注意事项** + +store 的方法名称(List、Get、Create、Update)需要根据实际 store 接口调整。运行 `go build ./telegram/...` 后根据编译错误逐一对齐方法名。 + +**监工补充:这一节不能照抄上面的示例实现,至少要修正以下事实** + +- `ListTraders()` / `ListStrategies()` / `ListModels()` / `ListExchanges()` 的返回值都应与真实 store 一致,当前仓库大多是指针切片 +- `StartTrader()` / `StopTrader()` 不能调用不存在的 `manager` 方法,必须镜像 `api/server.go` 的启动/停止流程 +- `CreateStrategy()` 不能假设 `Strategy.ID` 是整数;请复用现有 API 的 `uuid.New().String()` 方案 +- `CreateModel()` / `CreateExchange()` 不能假设 store 会返回新建对象;真实接口要么返回 `error`,要么返回 `(id, error)` +- `GetPositions()` / `GetEquitySummary()` 需要在 `service` 内封装真实查询逻辑,不能调用仓库中不存在的 `ListByTrader()` / `Latest()` + +**Step 3: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +Expected: 只可能有 store 方法名不匹配的错误,逐一修正即可。 + +**Step 4: Commit** + +```bash +git add telegram/service/nofx.go +git commit -m "feat(telegram): add NofxService facade layer" +``` + +--- + +### Task 3: 会话记忆 telegram/session/memory.go + +**Files:** +- Create: `telegram/session/memory.go` + +借鉴 openclaw compaction 模式:token 超阈值 → LLM 静默压缩 → 写入长期记忆 → 清空短期历史。 + +**Step 1: 创建文件** + +```go +package session + +import ( + "fmt" + "nofx/mcp" + "strings" +) + +const ( + // When short-term history exceeds this token estimate, trigger compaction + compactionThresholdTokens = 3000 + // Rough estimate: 1 token ≈ 4 chars (Chinese ~2 chars/token) + charsPerToken = 3 +) + +// Message represents a single conversation turn +type Message struct { + Role string // "user" or "assistant" + Content string +} + +// Memory manages conversation history with automatic compaction. +// Inspired by openclaw's compaction pattern. +type Memory struct { + LongTerm string // Durable summary (survives compaction) + 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 needed +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 LLM intent parsing +func (m *Memory) BuildContext() string { + var sb strings.Builder + if m.LongTerm != "" { + sb.WriteString("【历史摘要】\n") + sb.WriteString(m.LongTerm) + sb.WriteString("\n\n") + } + if len(m.ShortTerm) > 0 { + sb.WriteString("【近期对话】\n") + for _, msg := range m.ShortTerm { + sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content)) + } + } + return sb.String() +} + +// Reset clears session (called on /start or new session) +func (m *Memory) Reset() { + m.ShortTerm = []Message{} + // LongTerm is preserved intentionally +} + +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 (silent, user doesn't see this) +func (m *Memory) compact() { + if m.llm == nil || len(m.ShortTerm) == 0 { + return + } + + history := m.BuildContext() + systemPrompt := `你是一个对话摘要助手。将以下交易配置对话压缩为简洁摘要。 + +必须保留: +- 用户正在配置什么(策略/交易所/大模型/交易员) +- 已确认的参数(交易对、杠杆、止损比例、指标等) +- 待确认或缺失的参数 +- 用户表达的偏好和要求 + +输出格式:纯文本摘要,不超过200字。` + + summary, err := m.llm.CallWithMessages(systemPrompt, history) + if err != nil { + // Compaction failed: keep short-term as-is, don't lose data + return + } + + // Write summary to long-term, clear short-term + if m.LongTerm != "" { + m.LongTerm = m.LongTerm + "\n" + summary + } else { + m.LongTerm = summary + } + m.ShortTerm = []Message{} +} +``` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/session/memory.go +git commit -m "feat(telegram): add conversation memory with openclaw-style compaction" +``` + +--- + +### Task 4: 会话状态 telegram/session/session.go + +**Files:** +- Create: `telegram/session/session.go` + +**Step 1: 创建文件** + +```go +package session + +import ( + "nofx/mcp" + "sync" + "time" +) + +// Intent represents what the user is currently trying to do +type Intent string + +const ( + IntentNone Intent = "" + IntentConfigStrategy Intent = "config_strategy" + IntentConfigExchange Intent = "config_exchange" + IntentConfigModel Intent = "config_model" + IntentConfigTrader Intent = "config_trader" + IntentQueryPositions Intent = "query_positions" + IntentControlTrader Intent = "control_trader" + IntentUpdatePrompt Intent = "update_prompt" +) + +// Session holds state for a single Telegram conversation +type Session struct { + ChatID int64 + Intent Intent + Params map[string]string // collected parameters so far + Memory *Memory + UpdatedAt time.Time +} + +// Manager manages all active sessions (one per chat ID) +type Manager struct { + mu sync.RWMutex + sessions map[int64]*Session + llm mcp.AIClient +} + +func NewManager(llm mcp.AIClient) *Manager { + return &Manager{ + sessions: make(map[int64]*Session), + llm: llm, + } +} + +// Get returns or creates a session for the given chat ID +func (m *Manager) Get(chatID int64) *Session { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[chatID] + if !ok { + s = &Session{ + ChatID: chatID, + Intent: IntentNone, + Params: make(map[string]string), + Memory: NewMemory(m.llm), + UpdatedAt: time.Now(), + } + m.sessions[chatID] = s + } + s.UpdatedAt = time.Now() + return s +} + +// Reset clears session intent and params (keeps memory) +func (s *Session) Reset() { + s.Intent = IntentNone + s.Params = make(map[string]string) +} + +// ResetFull clears everything including memory (on /start command) +func (s *Session) ResetFull() { + s.Reset() + s.Memory.Reset() +} +``` + +**监工补充:这里的伪代码与注释不一致** + +- 当前 `Memory.Reset()` 只清空短期历史,不会清空 `LongTerm` +- 如果 `/start` 的产品语义是“完全重置”,这里必须改成真正清空长期摘要,或者直接新建一个 `Memory` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/session/session.go +git commit -m "feat(telegram): add session state manager" +``` + +--- + +### Task 5: LLM 意图解析 telegram/intent/parser.go + +**Files:** +- Create: `telegram/intent/parser.go` + +复用 `nofx/mcp` 的现有 LLM 客户端,不引入新依赖。 + +**Step 1: 创建文件** + +```go +package intent + +import ( + "encoding/json" + "nofx/mcp" + "strings" +) + +// ParsedIntent is the structured output from LLM intent parsing +type ParsedIntent struct { + Action string `json:"action"` // e.g. "config_strategy", "query_positions" + Params map[string]string `json:"params"` // extracted parameters + Missing []string `json:"missing"` // params still needed + Reply string `json:"reply"` // what bot should say to user +} + +const systemPrompt = `你是 NOFX 交易系统的对话助手。分析用户消息,提取交易配置意图和参数。 + +支持的操作(action): +- config_strategy: 创建/修改策略(需要:name, coins, indicators, max_position_pct, stop_loss_pct) +- config_exchange: 配置交易所(需要:exchange_type, api_key, secret_key) +- config_model: 配置大模型(需要:provider, api_key, model) +- config_trader: 配置交易员(需要:name, model_id, exchange_id, strategy_id) +- query_positions: 查询持仓(需要:trader_id 或 "all") +- query_equity: 查询账户余额/盈亏 +- control_start: 启动交易员(需要:trader_id 或 trader_name) +- control_stop: 停止交易员(需要:trader_id 或 trader_name) +- control_close: 紧急平仓(需要:trader_id, symbol) +- update_prompt: 修改策略 Prompt(需要:strategy_id 或 strategy_name, prompt) +- unknown: 无法识别 + +输出严格 JSON 格式: +{ + "action": "action_name", + "params": {"key": "value"}, + "missing": ["param1", "param2"], + "reply": "对用户的回复(询问缺失参数或确认操作)" +} + +安全要求:API Key 等敏感信息原样保留在 params 中,不要截断或修改。` + +// Parser uses LLM to parse user message into structured intent +type Parser struct { + llm mcp.AIClient +} + +func NewParser(llm mcp.AIClient) *Parser { + return &Parser{llm: llm} +} + +// Parse sends user message + conversation context to LLM, returns structured intent +func (p *Parser) Parse(userMessage, conversationContext string) (*ParsedIntent, error) { + userPrompt := userMessage + if conversationContext != "" { + userPrompt = conversationContext + "\n\n【当前消息】\n" + userMessage + } + + resp, err := p.llm.CallWithMessages(systemPrompt, userPrompt) + if err != nil { + return nil, err + } + + // Extract JSON from response (LLM may wrap in markdown code block) + jsonStr := extractJSON(resp) + + var result ParsedIntent + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + // Fallback: return unknown intent with raw response as reply + return &ParsedIntent{ + Action: "unknown", + Reply: "抱歉,我没有理解你的意思。请描述你想做什么,例如:「帮我创建一个 BTC 策略」", + }, nil + } + return &result, nil +} + +func extractJSON(s string) string { + // Strip markdown code block if present + s = strings.TrimSpace(s) + if idx := strings.Index(s, "```json"); idx >= 0 { + s = s[idx+7:] + } else if idx := strings.Index(s, "```"); idx >= 0 { + s = s[idx+3:] + } + if idx := strings.LastIndex(s, "```"); idx >= 0 { + s = s[:idx] + } + // Find first { to last } + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start >= 0 && end > start { + return s[start : end+1] + } + return s +} +``` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/intent/parser.go +git commit -m "feat(telegram): add LLM intent parser" +``` + +--- + +### Task 6: 业务处理 telegram/handler/handler.go + +**Files:** +- Create: `telegram/handler/handler.go` + +handler 只调 service/ 和 intent/,不直接碰 store/manager。 + +**Step 1: 创建文件** + +```go +package handler + +import ( + "fmt" + "nofx/telegram/intent" + "nofx/telegram/service" + "nofx/telegram/session" + "strings" +) + +// Handler dispatches parsed intents to the right operation +type Handler struct { + svc *service.NofxService + parser *intent.Parser + sessions *session.Manager +} + +func New(svc *service.NofxService, parser *intent.Parser, sessions *session.Manager) *Handler { + return &Handler{svc: svc, parser: parser, sessions: sessions} +} + +// Handle processes a user message and returns the bot reply +func (h *Handler) Handle(chatID int64, userMessage string) string { + sess := h.sessions.Get(chatID) + + // Record user message in memory + sess.Memory.Add("user", userMessage) + + // Build conversation context for LLM + ctx := sess.Memory.BuildContext() + + // Parse intent via LLM + parsed, err := h.parser.Parse(userMessage, ctx) + if err != nil { + return "❌ 解析失败,请重试" + } + + // Merge newly extracted params into session + for k, v := range parsed.Params { + sess.Params[k] = v + } + + // If there are missing params, ask user + if len(parsed.Missing) > 0 { + sess.Intent = session.Intent(parsed.Action) + reply := parsed.Reply + sess.Memory.Add("assistant", reply) + return reply + } + + // Execute the action + reply := h.execute(sess, parsed) + sess.Memory.Add("assistant", reply) + sess.Reset() // clear intent after successful execution + return reply +} + +func (h *Handler) execute(sess *session.Session, parsed *intent.ParsedIntent) string { + params := sess.Params + + switch parsed.Action { + case "config_strategy": + return h.createStrategy(params) + + case "config_exchange": + return h.createExchange(params) + + case "config_model": + return h.createModel(params) + + case "query_positions": + return h.queryPositions(params) + + case "query_equity": + return h.queryEquity(params) + + case "control_start": + return h.startTrader(params) + + case "control_stop": + return h.stopTrader(params) + + case "update_prompt": + return h.updatePrompt(params) + + default: + return parsed.Reply + } +} + +func (h *Handler) createStrategy(params map[string]string) string { + name := params["name"] + if name == "" { + name = "我的策略" + } + // Build a minimal strategy config JSON from params + // Full StrategyConfig is complex; we start with essential fields + configJSON := buildStrategyConfigJSON(params) + strategy, err := h.svc.CreateStrategy(name, configJSON) + if err != nil { + return fmt.Sprintf("❌ 创建策略失败: %v", err) + } + return fmt.Sprintf("✅ 策略「%s」已创建(ID: %d)\n\n配置摘要:\n%s", strategy.Name, strategy.ID, formatParams(params)) +} + +func (h *Handler) createExchange(params map[string]string) string { + exType := params["exchange_type"] + apiKey := params["api_key"] + secretKey := params["secret_key"] + ex, err := h.svc.CreateExchange(exType, apiKey, secretKey) + if err != nil { + return fmt.Sprintf("❌ 配置交易所失败: %v", err) + } + return fmt.Sprintf("✅ %s 交易所已配置(ID: %d)", ex.ExchangeType, ex.ID) +} + +func (h *Handler) createModel(params map[string]string) string { + provider := params["provider"] + apiKey := params["api_key"] + model := params["model"] + m, err := h.svc.CreateModel(provider, apiKey, model) + if err != nil { + return fmt.Sprintf("❌ 配置大模型失败: %v", err) + } + return fmt.Sprintf("✅ %s (%s) 已配置(ID: %d)", m.Provider, m.Model, m.ID) +} + +func (h *Handler) queryPositions(params map[string]string) string { + traderID := params["trader_id"] + if traderID == "" { + traders, err := h.svc.ListTraders() + if err != nil || len(traders) == 0 { + return "❌ 没有找到交易员" + } + traderID = traders[0].ID + } + positions, err := h.svc.GetPositions(traderID) + if err != nil { + return fmt.Sprintf("❌ 查询持仓失败: %v", err) + } + if len(positions) == 0 { + return "📭 当前无持仓" + } + var sb strings.Builder + sb.WriteString("📊 当前持仓:\n") + for _, p := range positions { + sb.WriteString(fmt.Sprintf("• %s %s | 入场: %.4f | 未实现P&L: %.2f USDT\n", + p.Symbol, p.Side, p.EntryPrice, p.UnrealizedPnl)) + } + return sb.String() +} + +func (h *Handler) queryEquity(params map[string]string) string { + traders, err := h.svc.ListTraders() + if err != nil || len(traders) == 0 { + return "❌ 没有找到交易员" + } + traderID := params["trader_id"] + if traderID == "" { + traderID = traders[0].ID + } + eq, err := h.svc.GetEquitySummary(traderID) + if err != nil { + return fmt.Sprintf("❌ 查询余额失败: %v", err) + } + return fmt.Sprintf("💰 账户余额:%.2f USDT", eq.TotalBalance) +} + +func (h *Handler) startTrader(params map[string]string) string { + traderID := params["trader_id"] + if err := h.svc.StartTrader(traderID); err != nil { + return fmt.Sprintf("❌ 启动失败: %v", err) + } + return "✅ 交易员已启动" +} + +func (h *Handler) stopTrader(params map[string]string) string { + traderID := params["trader_id"] + if err := h.svc.StopTrader(traderID); err != nil { + return fmt.Sprintf("❌ 停止失败: %v", err) + } + return "✅ 交易员已停止" +} + +func (h *Handler) updatePrompt(params map[string]string) string { + // strategy_id must be numeric; convert from params + strategyIDStr := params["strategy_id"] + var strategyID uint + fmt.Sscanf(strategyIDStr, "%d", &strategyID) + prompt := params["prompt"] + if err := h.svc.UpdateStrategyPrompt(strategyID, prompt); err != nil { + return fmt.Sprintf("❌ 更新 Prompt 失败: %v", err) + } + return "✅ 策略 Prompt 已更新" +} + +// buildStrategyConfigJSON builds a minimal valid StrategyConfig JSON from params +func buildStrategyConfigJSON(params map[string]string) string { + coins := params["coins"] + if coins == "" { + coins = "BTC" + } + stopLoss := params["stop_loss_pct"] + if stopLoss == "" { + stopLoss = "5" + } + maxPos := params["max_position_pct"] + if maxPos == "" { + maxPos = "20" + } + indicators := params["indicators"] + + return fmt.Sprintf(`{ + "strategy_type": "ai_trading", + "coin_source": {"source_type": "static", "static_coins": [%q]}, + "indicators": {"enable_rsi": %v, "enable_macd": %v}, + "risk_control": {"stop_loss_pct": %s, "max_position_pct": %s} + }`, + coins, + strings.Contains(indicators, "RSI"), + strings.Contains(indicators, "MACD"), + stopLoss, + maxPos, + ) +} + +func formatParams(params map[string]string) string { + var sb strings.Builder + for k, v := range params { + if k == "api_key" || k == "secret_key" { + v = "***" + } + sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) + } + return sb.String() +} +``` + +**监工补充:这里至少有 6 个会直接出错或行为错误的点** + +1. 当前写法会把“当前消息”重复注入 LLM 上下文。 + - `sess.Memory.Add("user", userMessage)` 已经把本轮消息写进历史 + - `parser.Parse(userMessage, ctx)` 又会把 `userMessage` 拼到 `conversationContext` 后面 + - 二选一修正:要么先 parse 再写 memory,要么 `Parse()` 不再重复追加当前消息 + +2. `store.TraderPosition` 没有 `UnrealizedPnl` 字段。 + - 首版查询持仓只能返回仓位基础信息,或另找真实未实现盈亏来源 + +3. `store.EquitySnapshot` 没有 `TotalBalance` 字段,真实字段是 `TotalEquity` + +4. `strategy.ID` 不是 `%d`,`AIModel` 也没有示例中的 `Model` 字段 + +5. `buildStrategyConfigJSON()` 示例不符合当前仓库真实 `StrategyConfig` + - `risk_control.stop_loss_pct` + - `risk_control.max_position_pct` + 这些都不是当前结构里的真实字段名 + - 首版如果做策略写入,必须基于 `store.GetDefaultStrategyConfig("zh")` 组装 + +6. `updatePrompt()` 不能直接调用“按数值 strategyID 更新顶层 prompt”的假接口 + - 真实实现应该更新 `Strategy.Config` 里的 `CustomPrompt` 或 prompt sections + - 或者先把首版 prompt 修改目标收缩为 `Trader().UpdateCustomPrompt(...)` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/handler/handler.go +git commit -m "feat(telegram): add intent handler with 6 feature areas" +``` + +--- + +### Task 7: Bot 入口 telegram/bot.go + +**Files:** +- Create: `telegram/bot.go` + +**Step 1: 创建文件** + +```go +package telegram + +import ( + "nofx/config" + "nofx/logger" + "nofx/manager" + "nofx/mcp" + "nofx/store" + "nofx/telegram/handler" + "nofx/telegram/intent" + "nofx/telegram/service" + "nofx/telegram/session" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// Start initializes and runs the Telegram bot. +// Called from main.go as a goroutine. +func Start(cfg *config.Config, st *store.Store, tm *manager.TraderManager) { + if cfg.TelegramBotToken == "" { + logger.Info("📵 Telegram bot not configured (TELEGRAM_BOT_TOKEN not set), skipping") + return + } + + bot, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) + if err != nil { + logger.Errorf("❌ Failed to start Telegram bot: %v", err) + return + } + + logger.Infof("🤖 Telegram bot started: @%s", bot.Self.UserName) + + // Build the LLM client for intent parsing (use DeepSeek by default, same as backtest) + llmClient := mcp.New() + // Configure with whatever key is available in env (intent parsing is lightweight) + // The service layer will use store to get user-configured models for actual trading + + svc := service.New(st, tm) + parser := intent.NewParser(llmClient) + sessions := session.NewManager(llmClient) + h := handler.New(svc, parser, sessions) + + 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 + + // Access control: only allow configured admin chat ID + if cfg.TelegramAdminChatID != 0 && chatID != cfg.TelegramAdminChatID { + msg := tgbotapi.NewMessage(chatID, "⛔ 未授权访问") + bot.Send(msg) + continue + } + + text := update.Message.Text + if text == "" { + continue + } + + // Handle /start command + if text == "/start" { + sessions.Get(chatID).ResetFull() + reply := tgbotapi.NewMessage(chatID, welcomeMessage()) + bot.Send(reply) + continue + } + + // Process message + reply := h.Handle(chatID, text) + msg := tgbotapi.NewMessage(chatID, reply) + msg.ParseMode = "Markdown" + bot.Send(msg) + } +} + +func welcomeMessage() string { + return `👋 欢迎使用 NOFX 交易助手! + +你可以用自然语言配置和管理你的交易系统: + +📋 *配置功能* +• 「帮我创建一个 BTC 策略,RSI+MACD,止损 8%」 +• 「配置 Binance 交易所」 +• 「添加 DeepSeek 大模型」 +• 「创建一个交易员」 + +📊 *查询功能* +• 「查看当前持仓」 +• 「查看账户余额」 + +⚙️ *控制功能* +• 「启动交易员」 +• 「停止交易员」 +• 「修改策略 Prompt」 + +输入 /start 重置会话` +} +``` + +**监工补充:本节伪代码需要先修正两个问题** + +1. `mcp.New()` 在当前仓库里不存在,必须改成真实可用的构造器 +2. `msg.ParseMode = "Markdown"` 首版不要开,先用纯文本,避免用户内容触发格式错误或意外转义 + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/bot.go +git commit -m "feat(telegram): add Telegram bot entry point with access control" +``` + +--- + +### Task 8: 接入 main.go(3 行改动) + +**Files:** +- Modify: `main.go` + +**Step 1: 加 import** + +在 main.go 的 import 块加: + +```go +"nofx/telegram" +``` + +**Step 2: 在 API Server 启动之后加 3 行** + +找到这段代码: +```go +// Start API server +server := api.NewServer(...) +go func() { ... }() +``` + +在其后加: + +```go +// Start Telegram bot (if configured) +go telegram.Start(cfg, st, traderManager) +logger.Info("🤖 Telegram bot goroutine started") +``` + +**Step 3: 完整构建** + +```bash +cd /Users/yida/gopro/open-nofx && go build -o nofx . +``` + +Expected: 成功编译,无错误 + +**Step 4: Commit** + +```bash +git add main.go +git commit -m "feat(telegram): wire Telegram bot into main startup (3 lines)" +``` + +--- + +### Task 9: .env.example 文档更新 + +**Files:** +- Modify: `.env.example` 或 `.env`(若存在) + +**Step 1: 在 .env.example 末尾加** + +```env +# Telegram Bot Configuration +# Get token from @BotFather on Telegram +TELEGRAM_BOT_TOKEN= +# Get your chat ID from @userinfobot on Telegram +TELEGRAM_ADMIN_CHAT_ID= +``` + +**Step 2: Commit** + +```bash +git add .env.example +git commit -m "docs: add Telegram bot configuration to .env.example" +``` + +--- + +### Task 10: 手动集成测试 + +**Step 1: 配置环境变量** + +```bash +export TELEGRAM_BOT_TOKEN=你的bot_token +export TELEGRAM_ADMIN_CHAT_ID=你的chat_id +``` + +**Step 2: 启动 NOFX** + +```bash +cd /Users/yida/gopro/open-nofx && ./nofx +``` + +Expected 日志: +``` +✅ Configuration loaded +🤖 Telegram bot started: @your_bot_name +✅ System started successfully +``` + +**Step 3: 测试对话流程** + +在 Telegram 发送: +1. `/start` → 收到欢迎消息 +2. `查看当前持仓` → 返回持仓信息或「无持仓」 +3. `帮我创建一个 BTC 策略,RSI+MACD,止损 8%` → Bot 追问策略名 +4. `叫"主力BTC"` → 策略创建成功 + +**Step 4: 验证访问控制** + +用其他账号发送消息 → 收到「⛔ 未授权访问」 + +--- + +## 关键约束备忘 + +1. **`service/nofx.go` 是唯一接触 store/manager 的文件**,handler 不能绕过它 +2. **compaction 静默发生**,用户看不到压缩过程 +3. **LLM 客户端必须使用真实存在的构造器**,不能写 `mcp.New()` +4. **当前仓库的 `store` / `manager` 接口与本文示例存在偏差**,实现时必须以源码为准 +5. **首轮目标是“最小可用闭环”而不是功能铺满**,先交付查询与启停,再扩到配置写入 + +## 监工验收清单 + +1. `go build ./telegram/...` 成功 +2. `go build ./...` 成功 +3. 未授权 chat 收到拒绝消息,且不会进入业务逻辑 +4. `/start` 后会话状态确实被清空,且重置语义与代码一致 +5. 启动/停止交易员的行为与现有 HTTP API 一致 +6. 没有任何日志或回复泄露密钥、私钥、passphrase +7. 查询接口用到的字段名全部来自真实 struct,而不是文档猜测 + +## 后续可扩展 + +- 主动推送:NOFX 交易决策 → 推送到 Telegram +- 多语言:intent parser 的 systemPrompt 支持英文 +- 图表:发送持仓/权益曲线截图(需 TradingView Lightweight Charts 截图服务) diff --git a/main.go b/main.go index a0987f81..5758d4fb 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/mcp/client.go b/mcp/client.go index 3e778fb1..cd78717c 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -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 +} diff --git a/mcp/interface.go b/mcp/interface.go index 696b03ba..2dbcb03c 100644 --- a/mcp/interface.go +++ b/mcp/interface.go @@ -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) diff --git a/store/ai_model.go b/store/ai_model.go index a9047866..b74d5780 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -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 { diff --git a/store/store.go b/store/store.go index 8119b935..5e6ec457 100644 --- a/store/store.go +++ b/store/store.go @@ -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 { diff --git a/store/telegram_config.go b/store/telegram_config.go new file mode 100644 index 00000000..6762d973 --- /dev/null +++ b/store/telegram_config.go @@ -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 = "" + } + 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 +} diff --git a/telegram/agent/agent.go b/telegram/agent/agent.go new file mode 100644 index 00000000..0a16998b --- /dev/null +++ b/telegram/agent/agent.go @@ -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 : 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 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 tag appears. + // Also hold back the last (len("")-1) chars of accumulated text to + // avoid showing partial opening tags (e.g. "<", "") // 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, ""); 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 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 ... 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, ""); idx >= 0 { + return strings.TrimSpace(s[:idx]) + } + return s +} + +// ResetMemory clears conversation history (called on /start). +func (a *Agent) ResetMemory() { + a.memory.ResetFull() +} diff --git a/telegram/agent/agent_test.go b/telegram/agent/agent_test.go new file mode 100644 index 00000000..809d84b0 --- /dev/null +++ b/telegram/agent/agent_test.go @@ -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.{"method":"GET","path":"/api/my-traders","body":{}}`, + "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.{"method":"GET","path":"/api/account","body":{}}`, + `Now checking positions.{"method":"GET","path":"/api/positions","body":{}}`, + "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{ + `{"method":"GET","path":"/api/account","body":{}}`, + "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.{"method":"POST","path":"/api/traders/t1/stop","body":{}}` + 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(`NOT JSON`) + if req != nil { + t.Fatal("expected nil for malformed JSON") + } + }) +} diff --git a/telegram/agent/apicall.go b/telegram/agent/apicall.go new file mode 100644 index 00000000..4321a064 --- /dev/null +++ b/telegram/agent/apicall.go @@ -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 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 ... from LLM response. +// Returns (nil, original) if not found or malformed JSON. +func parseAPICall(resp string) (*apiRequest, string) { + const openTag = "" + const closeTag = "" + + 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]) +} diff --git a/telegram/agent/manager.go b/telegram/agent/manager.go new file mode 100644 index 00000000..e3acf34b --- /dev/null +++ b/telegram/agent/manager.go @@ -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 +} diff --git a/telegram/agent/prompt.go b/telegram/agent/prompt.go new file mode 100644 index 00000000..c13160aa --- /dev/null +++ b/telegram/agent/prompt.go @@ -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: +{"method":"GET","path":"/api/xxx","body":{}} + +Rules: +- The tag must be the LAST thing in your message — nothing after it +- NEVER more than one 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 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) +} diff --git a/telegram/bot.go b/telegram/bot.go new file mode 100644 index 00000000..51cd83d8 --- /dev/null +++ b/telegram/bot.go @@ -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.` +} diff --git a/telegram/session/memory.go b/telegram/session/memory.go new file mode 100644 index 00000000..30e84b2c --- /dev/null +++ b/telegram/session/memory.go @@ -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{} +} diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index f0de17b2..bd5490ff 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -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(null) const [editingExchange, setEditingExchange] = useState(null) const [editingTrader, setEditingTrader] = useState(null) @@ -849,6 +852,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { + + + )} +
+ +

+ {zh ? 'Telegram Bot 配置' : 'Telegram Bot Setup'} +

+
+ + + + + {/* Step Indicator */} +
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ {zh ? '加载中...' : 'Loading...'} +
+ ) : ( + <> + {/* Step 0: Create bot via BotFather */} + {step === 0 && ( +
+
+
+ 🤖 +
+
+ {zh ? '第一步:在 Telegram 创建你的 Bot' : 'Step 1: Create your Bot in Telegram'} +
+
+
1. {zh ? '打开 Telegram,搜索' : 'Open Telegram, search for'} @BotFather
+
2. {zh ? '发送' : 'Send'} /newbot {zh ? '命令' : 'command'}
+
3. {zh ? '按提示输入 Bot 名称和用户名' : 'Follow prompts to set bot name and username'}
+
4. {zh ? 'BotFather 会返回一个 Token,复制它' : 'BotFather will return a Token, copy it'}
+
+
+
+
+ + + + {zh ? '打开 @BotFather' : 'Open @BotFather'} + + +
+ + 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' }} + /> +
+ {zh ? 'Token 格式:数字:字母数字串,如 123456789:ABCdef...' : 'Format: numbers:alphanumeric, e.g. 123456789:ABCdef...'} +
+
+ + + + +
+ )} + + {/* Step 1: Send /start to activate */} + {step === 1 && ( +
+
+
+ 📱 +
+
+ {zh ? '第二步:向你的 Bot 发送 /start' : 'Step 2: Send /start to your Bot'} +
+
+
1. {zh ? '在 Telegram 中搜索你刚创建的 Bot' : 'Search for your newly created Bot in Telegram'}
+
2. {zh ? '点击 Start 或发送' : 'Click Start or send'} /start
+
3. {zh ? 'Bot 会自动绑定到你的账号' : 'Bot will automatically bind to your account'}
+
+
+
+
+ + {config?.token_masked && ( +
+
+
+
+ {zh ? '当前 Token' : 'Current Token'} +
+
+ {config.token_masked} +
+
+
+ )} + +
+
+ {zh + ? '⏳ 等待你发送 /start... 发送后刷新页面查看状态' + : '⏳ Waiting for you to send /start... Refresh page after sending'} +
+
+ +
+ + +
+
+ )} + + {/* Step 2: Bound & active */} + {step === 2 && ( +
+
+
🎉
+
+ {zh ? 'Telegram Bot 已绑定!' : 'Telegram Bot is Active!'} +
+
+ {zh + ? '你现在可以通过 Telegram 用自然语言控制交易系统' + : 'You can now control the trading system via natural language in Telegram'} +
+
+ + {config?.token_masked && ( +
+
+
+
+ {zh ? 'Bot Token' : 'Bot Token'} +
+
+ {config.token_masked} +
+
+
+ )} + + {/* AI Model selector — works on active bot */} + { + setConfig((prev) => prev ? { ...prev, model_id: modelId } : prev) + }} + /> + + {/* What you can do */} +
+
+ {zh ? '支持的命令' : 'Supported Commands'} +
+ {[ + { 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) => ( +
+ + {item.cmd} + + {item.desc} +
+ ))} +
+ +
+ + +
+
+ )} + + )} +
+
+
+ ) +} + +// 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 ( +
+ +
+ + +
+
+ ) +} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index b387a884..294a1edc 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -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, diff --git a/web/src/i18n/strategy-translations.ts b/web/src/i18n/strategy-translations.ts index f27702df..cd15f2dd 100644 --- a/web/src/i18n/strategy-translations.ts +++ b/web/src/i18n/strategy-translations.ts @@ -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 diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 85433077..cf1c0649 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -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', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 84024439..fd3aa71f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { + const result = await httpClient.get(`${API_BASE}/telegram`) + if (!result.success) throw new Error('获取Telegram配置失败') + return result.data! + }, + + async updateTelegramConfig(token: string, modelId?: string): Promise { + const result = await httpClient.post(`${API_BASE}/telegram`, { bot_token: token, model_id: modelId ?? '' }) + if (!result.success) throw new Error('保存Telegram配置失败') + }, + + async unbindTelegram(): Promise { + const result = await httpClient.delete(`${API_BASE}/telegram/binding`) + if (!result.success) throw new Error('解绑Telegram失败') + }, + + async updateTelegramModel(modelId: string): Promise { + const result = await httpClient.post(`${API_BASE}/telegram/model`, { model_id: modelId }) + if (!result.success) throw new Error('更新Telegram模型失败') + }, } diff --git a/web/src/types.ts b/web/src/types.ts index 703dc611..6532d96f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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"