mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 20:11:13 +08:00
feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and ⏳ placeholder
- Account state injection at conversation start (models, exchanges,
strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ nofx_test
|
||||
# Go 相关
|
||||
*.test
|
||||
*.out
|
||||
.gocache/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
|
||||
66
api/route_registry.go
Normal file
66
api/route_registry.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RouteDoc holds documentation for a single API route.
|
||||
type RouteDoc struct {
|
||||
Method string
|
||||
Path string
|
||||
Description string
|
||||
Schema string // optional: full parameter/body schema documentation
|
||||
}
|
||||
|
||||
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
|
||||
var routeRegistry []RouteDoc
|
||||
|
||||
// route registers an HTTP route with a one-line description.
|
||||
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
|
||||
s.routeWithSchema(g, method, path, description, "", h)
|
||||
}
|
||||
|
||||
// routeWithSchema registers an HTTP route with full parameter schema documentation.
|
||||
// schema is injected verbatim into the API docs seen by the LLM.
|
||||
func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) {
|
||||
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
routeRegistry = append(routeRegistry, RouteDoc{
|
||||
Method: method,
|
||||
Path: fullPath,
|
||||
Description: description,
|
||||
Schema: schema,
|
||||
})
|
||||
switch method {
|
||||
case "GET":
|
||||
g.GET(path, h)
|
||||
case "POST":
|
||||
g.POST(path, h)
|
||||
case "PUT":
|
||||
g.PUT(path, h)
|
||||
case "DELETE":
|
||||
g.DELETE(path, h)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
|
||||
// Routes with schema documentation include full parameter details.
|
||||
func GetAPIDocs() string {
|
||||
var sb strings.Builder
|
||||
for _, r := range routeRegistry {
|
||||
sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
|
||||
if r.Schema != "" {
|
||||
// Indent each schema line for readability
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
330
api/server.go
330
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":"<string, required>","ai_model_id":"<string, required — use ID from GET /api/models, must be enabled>","exchange_id":"<string, required — use ID from GET /api/exchanges, must be enabled>","strategy_id":"<string, optional — use ID from GET /api/strategies>","scan_interval_minutes":<int, default 60>}
|
||||
Workflow: 1) GET /api/exchanges to find enabled exchange ID 2) GET /api/models to find enabled model ID 3) GET /api/strategies to find strategy ID 4) POST with all IDs`,
|
||||
s.handleCreateTrader)
|
||||
s.route(protected, "PUT", "/traders/:id", "Update trader configuration", s.handleUpdateTrader)
|
||||
s.route(protected, "DELETE", "/traders/:id", "Delete trader", s.handleDeleteTrader)
|
||||
s.route(protected, "POST", "/traders/:id/start", "Start trader — begins live trading", s.handleStartTrader)
|
||||
s.route(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading", s.handleStopTrader)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id/prompt", "Override the trader's AI system prompt",
|
||||
`Body: {"prompt":"<string — the full custom prompt text>"}`,
|
||||
s.handleUpdateTraderPrompt)
|
||||
s.route(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", s.handleSyncBalance)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/close-position", "Force-close an open position",
|
||||
`Body: {"symbol":"<string, e.g. BTCUSDT>"}`,
|
||||
s.handleClosePosition)
|
||||
s.route(protected, "PUT", "/traders/:id/competition", "Toggle competition leaderboard visibility", s.handleToggleCompetition)
|
||||
s.route(protected, "GET", "/traders/:id/grid-risk", "Get grid trading risk info", s.handleGetGridRiskInfo)
|
||||
|
||||
// AI model configuration
|
||||
protected.GET("/models", s.handleGetModelConfigs)
|
||||
protected.PUT("/models", s.handleUpdateModelConfigs)
|
||||
s.route(protected, "GET", "/models", "List AI model configs — returns id, name, provider, enabled status", s.handleGetModelConfigs)
|
||||
s.routeWithSchema(protected, "PUT", "/models", "Configure an AI model provider",
|
||||
`Body: {"models":{"<model_id>":{"enabled":<bool>,"api_key":"<string>","custom_api_url":"<string, leave empty to use provider default>","custom_model_name":"<string, leave empty to use provider default>"}}}
|
||||
model_id values: "openai","deepseek","qwen","kimi","grok","gemini","claude"
|
||||
Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.deepseek.com, qwen→dashscope.aliyuncs.com/compatible-mode/v1, kimi→api.moonshot.ai/v1, grok→api.x.ai/v1, gemini→generativelanguage.googleapis.com/v1beta/openai, claude→api.anthropic.com/v1`,
|
||||
s.handleUpdateModelConfigs)
|
||||
|
||||
// Exchange configuration
|
||||
protected.GET("/exchanges", s.handleGetExchangeConfigs)
|
||||
protected.POST("/exchanges", s.handleCreateExchange)
|
||||
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
|
||||
protected.DELETE("/exchanges/:id", s.handleDeleteExchange)
|
||||
s.route(protected, "GET", "/exchanges", "List exchange accounts — returns id, exchange_type, account_name, enabled", s.handleGetExchangeConfigs)
|
||||
s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account",
|
||||
`Body: {"exchange_type":"<string>","account_name":"<string, user label>","enabled":true,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, required for okx/gate/kucoin>"}
|
||||
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)
|
||||
Required fields by exchange:
|
||||
binance/bybit/bitget/indodax: api_key + secret_key
|
||||
okx/gate/kucoin: api_key + secret_key + passphrase
|
||||
hyperliquid: hyperliquid_wallet_addr
|
||||
aster: aster_user + aster_signer + aster_private_key
|
||||
lighter: lighter_wallet_addr + lighter_private_key + lighter_api_key_private_key + lighter_api_key_index`,
|
||||
s.handleCreateExchange)
|
||||
s.route(protected, "PUT", "/exchanges", "Update exchange configurations", s.handleUpdateExchangeConfigs)
|
||||
s.route(protected, "DELETE", "/exchanges/:id", "Delete exchange account", s.handleDeleteExchange)
|
||||
|
||||
// Telegram bot configuration
|
||||
s.route(protected, "GET", "/telegram", "Get Telegram bot configuration", s.handleGetTelegramConfig)
|
||||
s.route(protected, "POST", "/telegram", "Update Telegram bot token/model", s.handleUpdateTelegramConfig)
|
||||
s.route(protected, "POST", "/telegram/model", "Update Telegram bot AI model only", s.handleUpdateTelegramModel)
|
||||
s.route(protected, "DELETE", "/telegram/binding", "Unbind Telegram account", s.handleUnbindTelegram)
|
||||
|
||||
// Strategy management
|
||||
protected.GET("/strategies", s.handleGetStrategies)
|
||||
protected.GET("/strategies/active", s.handleGetActiveStrategy)
|
||||
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
|
||||
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
|
||||
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
|
||||
protected.GET("/strategies/:id", s.handleGetStrategy)
|
||||
protected.POST("/strategies", s.handleCreateStrategy)
|
||||
protected.PUT("/strategies/:id", s.handleUpdateStrategy)
|
||||
protected.DELETE("/strategies/:id", s.handleDeleteStrategy)
|
||||
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
||||
protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy)
|
||||
s.route(protected, "GET", "/strategies", "List user's strategies", s.handleGetStrategies)
|
||||
s.route(protected, "GET", "/strategies/active", "Get active strategy", s.handleGetActiveStrategy)
|
||||
s.route(protected, "GET", "/strategies/default-config", "Get default strategy config with all fields and sensible values — use as reference for building configs", s.handleGetDefaultStrategyConfig)
|
||||
s.route(protected, "POST", "/strategies/preview-prompt", "Preview the AI prompt that will be generated from a config", s.handlePreviewPrompt)
|
||||
s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun)
|
||||
s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy)
|
||||
s.routeWithSchema(protected, "POST", "/strategies", "Create a new trading strategy",
|
||||
`Body: {"name":"<string, required>","description":"<string, optional>","lang":"zh|en","config":<StrategyConfig object>}
|
||||
StrategyConfig fields:
|
||||
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
|
||||
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
|
||||
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
|
||||
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
|
||||
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
|
||||
indicators.klines.primary_count: number of candles (20-100)
|
||||
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
|
||||
indicators.klines.selected_timeframes: e.g. ["5m","15m","1h","4h"]
|
||||
indicators.enable_raw_klines: ALWAYS true (raw OHLCV required)
|
||||
indicators.enable_ema: true for trend-following (EMA crossover signals)
|
||||
indicators.enable_macd: true for trend + momentum confirmation
|
||||
indicators.enable_rsi: true for overbought/oversold, divergence detection
|
||||
indicators.enable_boll: true for volatility, range trading, breakout strategies
|
||||
indicators.enable_atr: true for volatility measurement and stop-loss sizing
|
||||
indicators.enable_volume: ALWAYS true
|
||||
indicators.enable_oi: ALWAYS true (open interest data)
|
||||
indicators.enable_funding_rate: ALWAYS true
|
||||
indicators.ema_periods: [20,50] default, [9,21] for faster signals
|
||||
indicators.rsi_periods: [7,14] default
|
||||
indicators.atr_periods: [14] default
|
||||
indicators.boll_periods: [20] default
|
||||
indicators.nofxos_api_key: ALWAYS "cm_568c67eae410d912c54c"
|
||||
indicators.enable_quant_data: ALWAYS true
|
||||
indicators.enable_quant_oi: ALWAYS true
|
||||
indicators.enable_quant_netflow: ALWAYS true
|
||||
indicators.enable_oi_ranking: ALWAYS true, oi_ranking_duration:"1h", oi_ranking_limit:10
|
||||
indicators.enable_netflow_ranking: ALWAYS true, netflow_ranking_duration:"1h", netflow_ranking_limit:10
|
||||
indicators.enable_price_ranking: ALWAYS true, price_ranking_duration:"1h,4h,24h", price_ranking_limit:10
|
||||
risk_control.max_positions: max simultaneous positions (1=single coin, 3=diversified, 5=wide)
|
||||
risk_control.btc_eth_max_leverage: BTC/ETH leverage (conservative:3-5, moderate:5-10, aggressive:10-20)
|
||||
risk_control.altcoin_max_leverage: altcoin leverage (usually lower than BTC leverage)
|
||||
risk_control.btc_eth_max_position_value_ratio: max position size as multiple of equity (default 5)
|
||||
risk_control.altcoin_max_position_value_ratio: default 1
|
||||
risk_control.max_margin_usage: 0.5-0.95 (default 0.9 = use up to 90% margin)
|
||||
risk_control.min_position_size: minimum USDT per trade (default 12)
|
||||
risk_control.min_risk_reward_ratio: minimum profit/loss ratio required (default 3 = 3:1)
|
||||
risk_control.min_confidence: minimum AI confidence to open position (default 75, range 60-90)
|
||||
prompt_sections.role_definition: describe the AI's trading persona and goal
|
||||
prompt_sections.trading_frequency: guidelines on how often to trade
|
||||
prompt_sections.entry_standards: conditions that must align before entering a position
|
||||
prompt_sections.decision_process: step-by-step decision-making framework`,
|
||||
s.handleCreateStrategy)
|
||||
s.routeWithSchema(protected, "PUT", "/strategies/:id", "Update an existing strategy — WORKFLOW: 1) GET /api/strategies/:id first to read current config 2) Merge your changes into the full config 3) PUT with complete merged config 4) GET again to verify saved values",
|
||||
`Body: {"name":"<string>","description":"<string>","config":<complete StrategyConfig — same structure as POST /api/strategies>}
|
||||
IMPORTANT: config is merged with existing values server-side, but always send the complete section you are modifying.
|
||||
After updating, always GET /api/strategies/:id to verify and show the user actual saved values.`,
|
||||
s.handleUpdateStrategy)
|
||||
s.route(protected, "DELETE", "/strategies/:id", "Delete strategy", s.handleDeleteStrategy)
|
||||
s.route(protected, "POST", "/strategies/:id/activate", "Set strategy as active for a trader", s.handleActivateStrategy)
|
||||
s.route(protected, "POST", "/strategies/:id/duplicate", "Duplicate strategy", s.handleDuplicateStrategy)
|
||||
|
||||
// Debate Arena
|
||||
protected.GET("/debates", s.debateHandler.HandleListDebates)
|
||||
protected.GET("/debates/personalities", s.debateHandler.HandleGetPersonalities)
|
||||
protected.GET("/debates/:id", s.debateHandler.HandleGetDebate)
|
||||
protected.POST("/debates", s.debateHandler.HandleCreateDebate)
|
||||
protected.POST("/debates/:id/start", s.debateHandler.HandleStartDebate)
|
||||
protected.POST("/debates/:id/cancel", s.debateHandler.HandleCancelDebate)
|
||||
protected.POST("/debates/:id/execute", s.debateHandler.HandleExecuteDebate)
|
||||
protected.DELETE("/debates/:id", s.debateHandler.HandleDeleteDebate)
|
||||
protected.GET("/debates/:id/messages", s.debateHandler.HandleGetMessages)
|
||||
protected.GET("/debates/:id/votes", s.debateHandler.HandleGetVotes)
|
||||
protected.GET("/debates/:id/stream", s.debateHandler.HandleDebateStream)
|
||||
s.route(protected, "GET", "/debates", "List debates", s.debateHandler.HandleListDebates)
|
||||
s.route(protected, "GET", "/debates/personalities", "Available AI personalities", s.debateHandler.HandleGetPersonalities)
|
||||
s.route(protected, "GET", "/debates/:id", "Get debate details", s.debateHandler.HandleGetDebate)
|
||||
s.route(protected, "POST", "/debates", "Create debate", s.debateHandler.HandleCreateDebate)
|
||||
s.route(protected, "POST", "/debates/:id/start", "Start debate", s.debateHandler.HandleStartDebate)
|
||||
s.route(protected, "POST", "/debates/:id/cancel", "Cancel debate", s.debateHandler.HandleCancelDebate)
|
||||
s.route(protected, "POST", "/debates/:id/execute", "Execute debate consensus decision", s.debateHandler.HandleExecuteDebate)
|
||||
s.route(protected, "DELETE", "/debates/:id", "Delete debate", s.debateHandler.HandleDeleteDebate)
|
||||
s.route(protected, "GET", "/debates/:id/messages", "Get debate messages", s.debateHandler.HandleGetMessages)
|
||||
s.route(protected, "GET", "/debates/:id/votes", "Get debate votes", s.debateHandler.HandleGetVotes)
|
||||
s.route(protected, "GET", "/debates/:id/stream", "SSE stream for live debate", s.debateHandler.HandleDebateStream)
|
||||
|
||||
// Data for specified trader (using query parameter ?trader_id=xxx)
|
||||
protected.GET("/status", s.handleStatus)
|
||||
protected.GET("/account", s.handleAccount)
|
||||
protected.GET("/positions", s.handlePositions)
|
||||
protected.GET("/positions/history", s.handlePositionHistory)
|
||||
protected.GET("/trades", s.handleTrades)
|
||||
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||||
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
||||
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
|
||||
protected.GET("/decisions", s.handleDecisions)
|
||||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||||
protected.GET("/statistics", s.handleStatistics)
|
||||
s.route(protected, "GET", "/status", "Trader running status (?trader_id=)", s.handleStatus)
|
||||
s.route(protected, "GET", "/account", "Account balance and equity (?trader_id=)", s.handleAccount)
|
||||
s.route(protected, "GET", "/positions", "Current open positions (?trader_id=)", s.handlePositions)
|
||||
s.route(protected, "GET", "/positions/history", "Position history (?trader_id=)", s.handlePositionHistory)
|
||||
s.route(protected, "GET", "/trades", "Trade records (?trader_id=)", s.handleTrades)
|
||||
s.route(protected, "GET", "/orders", "All orders (?trader_id=)", s.handleOrders)
|
||||
s.route(protected, "GET", "/orders/:id/fills", "Order fill details", s.handleOrderFills)
|
||||
s.route(protected, "GET", "/open-orders", "Open orders from exchange (?trader_id=)", s.handleOpenOrders)
|
||||
s.route(protected, "GET", "/decisions", "AI trading decisions (?trader_id=)", s.handleDecisions)
|
||||
s.route(protected, "GET", "/decisions/latest", "Latest AI decisions (?trader_id=)", s.handleLatestDecisions)
|
||||
s.route(protected, "GET", "/statistics", "Trading statistics (?trader_id=)", s.handleStatistics)
|
||||
|
||||
// Backtest routes
|
||||
backtest := protected.Group("/backtest")
|
||||
@@ -3611,3 +3686,106 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SetTelegramReloadCh sets the channel used to signal the Telegram bot to reload
|
||||
func (s *Server) SetTelegramReloadCh(ch chan<- struct{}) {
|
||||
s.telegramReloadCh = ch
|
||||
}
|
||||
|
||||
// handleGetTelegramConfig returns current Telegram bot configuration and binding status
|
||||
func (s *Server) handleGetTelegramConfig(c *gin.Context) {
|
||||
cfg, err := s.store.TelegramConfig().Get()
|
||||
if err != nil {
|
||||
// Not configured yet - return empty state
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": false,
|
||||
"is_bound": false,
|
||||
"token_masked": "",
|
||||
"username": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mask bot token for security (show only last 6 chars)
|
||||
tokenMasked := ""
|
||||
if cfg.BotToken != "" {
|
||||
if len(cfg.BotToken) > 6 {
|
||||
tokenMasked = "***" + cfg.BotToken[len(cfg.BotToken)-6:]
|
||||
} else {
|
||||
tokenMasked = "***"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": cfg.BotToken != "",
|
||||
"is_bound": cfg.ChatID != 0,
|
||||
"username": cfg.Username,
|
||||
"bound_at": cfg.BoundAt,
|
||||
"token_masked": tokenMasked,
|
||||
"model_id": cfg.ModelID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateTelegramConfig saves bot token (+ optional model ID) and triggers bot hot-reload
|
||||
func (s *Server) handleUpdateTelegramConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.BotToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.TelegramConfig().Save(req.BotToken, req.ModelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
|
||||
return
|
||||
}
|
||||
|
||||
// Signal bot hot-reload if channel is available
|
||||
if s.telegramReloadCh != nil {
|
||||
select {
|
||||
case s.telegramReloadCh <- struct{}{}:
|
||||
default: // non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Bot token saved. Bot will reload automatically."})
|
||||
}
|
||||
|
||||
// handleUnbindTelegram removes Telegram user binding
|
||||
func (s *Server) handleUnbindTelegram(c *gin.Context) {
|
||||
if err := s.store.TelegramConfig().Unbind(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unbind"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Telegram binding removed"})
|
||||
}
|
||||
|
||||
// handleUpdateTelegramModel updates only the AI model used for Telegram replies (no token re-entry needed)
|
||||
func (s *Server) handleUpdateTelegramModel(c *gin.Context) {
|
||||
var req struct {
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.store.TelegramConfig().Get()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no Telegram config found, save a bot token first"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.TelegramConfig().Save(cfg.BotToken, req.ModelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model config"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "model_id": req.ModelID})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
File diff suppressed because it is too large
Load Diff
10
main.go
10
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)
|
||||
|
||||
123
mcp/client.go
123
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -137,6 +137,19 @@ func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// GetAnyEnabled returns the first enabled AI model across all users.
|
||||
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
|
||||
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("enabled = ? AND api_key != ''", true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// Update updates AI model, creates if not exists
|
||||
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
||||
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
|
||||
@@ -18,17 +18,18 @@ type Store struct {
|
||||
driver *DBDriver // Database driver for abstraction (legacy)
|
||||
|
||||
// Sub-stores (lazy initialization)
|
||||
user *UserStore
|
||||
aiModel *AIModelStore
|
||||
exchange *ExchangeStore
|
||||
trader *TraderStore
|
||||
decision *DecisionStore
|
||||
backtest *BacktestStore
|
||||
position *PositionStore
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
user *UserStore
|
||||
aiModel *AIModelStore
|
||||
exchange *ExchangeStore
|
||||
trader *TraderStore
|
||||
decision *DecisionStore
|
||||
backtest *BacktestStore
|
||||
position *PositionStore
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
telegramConfig TelegramConfigStore
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -160,6 +161,9 @@ func (s *Store) initTables() error {
|
||||
if err := s.Grid().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize grid tables: %w", err)
|
||||
}
|
||||
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize telegram config tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -293,6 +297,16 @@ func (s *Store) Grid() *GridStore {
|
||||
return s.grid
|
||||
}
|
||||
|
||||
// TelegramConfig gets Telegram bot configuration storage
|
||||
func (s *Store) TelegramConfig() TelegramConfigStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.telegramConfig == nil {
|
||||
s.telegramConfig = NewTelegramConfigStore(s.gdb)
|
||||
}
|
||||
return s.telegramConfig
|
||||
}
|
||||
|
||||
// Close closes database connection
|
||||
func (s *Store) Close() error {
|
||||
if s.driver != nil {
|
||||
|
||||
135
store/telegram_config.go
Normal file
135
store/telegram_config.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelegramConfig stores the Telegram bot binding (single row, always ID=1)
|
||||
type TelegramConfig struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
BotToken string `gorm:"column:bot_token"`
|
||||
ChatID int64 `gorm:"column:chat_id"`
|
||||
Username string `gorm:"column:username"` // @username for display
|
||||
BoundAt time.Time `gorm:"column:bound_at"`
|
||||
ModelID string `gorm:"column:model_id;default:''"` // AI model used for Telegram replies
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// String returns a safe string representation of TelegramConfig with the token masked.
|
||||
func (tc TelegramConfig) String() string {
|
||||
token := "***"
|
||||
if tc.BotToken == "" {
|
||||
token = "<not set>"
|
||||
}
|
||||
return fmt.Sprintf("TelegramConfig{ID:%d, ChatID:%d, Username:%q, BotToken:%s, BoundAt:%v}",
|
||||
tc.ID, tc.ChatID, tc.Username, token, tc.BoundAt)
|
||||
}
|
||||
|
||||
// TelegramConfigStore defines the interface for Telegram bot binding operations
|
||||
type TelegramConfigStore interface {
|
||||
Get() (*TelegramConfig, error) // Get current config (may not exist)
|
||||
SaveToken(botToken string) error // Save bot token only (Web UI sets this)
|
||||
Save(botToken, modelID string) error // Save bot token + selected AI model
|
||||
BindUser(chatID int64, username string) error // Called on first /start
|
||||
IsBound() (bool, error) // Check if any user is bound
|
||||
GetBoundChatID() (int64, error) // Get bound chat ID (0 if not bound)
|
||||
Unbind() error // Remove binding
|
||||
}
|
||||
|
||||
type telegramConfigStore struct {
|
||||
db *gorm.DB
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTelegramConfigStore creates a new TelegramConfigStore
|
||||
func NewTelegramConfigStore(db *gorm.DB) TelegramConfigStore {
|
||||
return &telegramConfigStore{db: db}
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) initTables() error {
|
||||
return s.db.AutoMigrate(&TelegramConfig{})
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Get() (*TelegramConfig, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) SaveToken(botToken string) error {
|
||||
return s.Save(botToken, "")
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Save(botToken, modelID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.BotToken = botToken
|
||||
cfg.ModelID = modelID
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) BindUser(chatID int64, username string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.ChatID = chatID
|
||||
cfg.Username = username
|
||||
cfg.BoundAt = time.Now()
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) IsBound() (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return cfg.ChatID != 0, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) GetBoundChatID() (int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return cfg.ChatID, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Unbind() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.db.Model(&TelegramConfig{}).Where("id = 1").Updates(map[string]interface{}{
|
||||
"chat_id": 0,
|
||||
"username": "",
|
||||
}).Error
|
||||
}
|
||||
228
telegram/agent/agent.go
Normal file
228
telegram/agent/agent.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/auth"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"nofx/telegram/session"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxIterations = 10
|
||||
|
||||
// Agent is a stateful AI agent for one Telegram chat.
|
||||
// It has a single tool (api_call) and an unbounded decision loop.
|
||||
type Agent struct {
|
||||
apiTool *apiCallTool
|
||||
getLLM func() mcp.AIClient
|
||||
memory *session.Memory
|
||||
systemPrompt string
|
||||
userID string
|
||||
}
|
||||
|
||||
// New creates an Agent for one chat session.
|
||||
func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
|
||||
return &Agent{
|
||||
apiTool: newAPICallTool(apiPort, botToken),
|
||||
getLLM: getLLM,
|
||||
memory: session.NewMemory(getLLM()),
|
||||
systemPrompt: systemPrompt,
|
||||
userID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
|
||||
// userID must match the actual registered user's ID so that bot-made changes
|
||||
// are visible in the frontend (they share the same user namespace).
|
||||
func GenerateBotToken(userID string) (string, error) {
|
||||
return auth.GenerateJWT(userID, "bot@internal")
|
||||
}
|
||||
|
||||
// buildAccountContext fetches the live account state (models, exchanges, strategies, traders,
|
||||
// and per-trader account summary + statistics) via the local API and returns it as a formatted
|
||||
// string for injection into the LLM context. This gives the LLM immediate awareness of what
|
||||
// is already configured and the current financial state, so it never asks the user for
|
||||
// information that already exists.
|
||||
func (a *Agent) buildAccountContext() string {
|
||||
type q struct {
|
||||
label string
|
||||
path string
|
||||
}
|
||||
queries := []q{
|
||||
{"AI Models", "/api/models"},
|
||||
{"Exchanges", "/api/exchanges"},
|
||||
{"Strategies", "/api/strategies"},
|
||||
{"Traders", "/api/my-traders"},
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("[Current Account State - Authenticated User ID: %s]\n\n", a.userID))
|
||||
|
||||
var tradersJSON string
|
||||
for _, query := range queries {
|
||||
result := a.apiTool.execute(&apiRequest{Method: "GET", Path: query.path})
|
||||
sb.WriteString(fmt.Sprintf("%s:\n%s\n\n", query.label, result))
|
||||
if query.path == "/api/my-traders" {
|
||||
tradersJSON = result
|
||||
}
|
||||
}
|
||||
|
||||
// For each running trader, fetch real-time account balance and trading statistics.
|
||||
var traders []struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Name string `json:"trader_name"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tradersJSON), &traders); err == nil {
|
||||
for _, t := range traders {
|
||||
if !t.IsRunning {
|
||||
continue
|
||||
}
|
||||
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
|
||||
sb.WriteString(fmt.Sprintf("Account [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, acct))
|
||||
|
||||
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
|
||||
sb.WriteString(fmt.Sprintf("Statistics [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, stats))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Run processes one user message through the agent loop.
|
||||
// Loop: LLM decides -> if <api_call>: execute, append result, loop -> if no tag: return reply.
|
||||
//
|
||||
// On the first message of a conversation, the current account state (models, exchanges,
|
||||
// strategies, traders) is automatically fetched and injected so the LLM knows what is
|
||||
// already configured without asking the user to repeat themselves.
|
||||
//
|
||||
// onChunk is optional. When non-nil, each LLM call is streamed:
|
||||
// - Chunks are forwarded to onChunk until an <api_call> tag appears in the accumulated text.
|
||||
// - After an api_call iteration completes, onChunk("⏳") resets the display to a thinking indicator.
|
||||
// - The final reply is streamed progressively via onChunk.
|
||||
func (a *Agent) Run(userMessage string, onChunk func(string)) string {
|
||||
llm := a.getLLM()
|
||||
if llm == nil {
|
||||
return "AI assistant unavailable. Please configure an AI model in the Web UI."
|
||||
}
|
||||
|
||||
// Build turn messages: history context prefix + current user message.
|
||||
// On the very first message (no history), prepend a live account state snapshot so the
|
||||
// LLM immediately knows what models, exchanges, strategies, and traders are configured.
|
||||
histCtx := a.memory.BuildContext()
|
||||
var firstMsg string
|
||||
if histCtx == "" {
|
||||
// First message in this conversation — fetch and inject account state.
|
||||
accountCtx := a.buildAccountContext()
|
||||
firstMsg = accountCtx + "\n[User Message]\n" + userMessage
|
||||
} else {
|
||||
firstMsg = histCtx + "\n---\nUser: " + userMessage
|
||||
}
|
||||
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstMsg)}
|
||||
|
||||
var lastResp string
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
req, err := mcp.NewRequestBuilder().
|
||||
WithSystemPrompt(a.systemPrompt).
|
||||
AddConversationHistory(turnMsgs).
|
||||
Build()
|
||||
if err != nil {
|
||||
logger.Errorf("Agent: failed to build request: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
var resp string
|
||||
if onChunk != nil {
|
||||
// Stream this call; suppress chunks once an <api_call> tag appears.
|
||||
// Also hold back the last (len("<api_call>")-1) chars of accumulated text to
|
||||
// avoid showing partial opening tags (e.g. "<", "<ap") before we can detect them.
|
||||
const tagLen = len("<api_call>") // 10
|
||||
const safeOffset = tagLen - 1 // 9: max prefix of tag we might have received
|
||||
|
||||
var apiTagSeen bool
|
||||
resp, err = llm.CallWithRequestStream(req, func(accumulated string) {
|
||||
if apiTagSeen {
|
||||
return
|
||||
}
|
||||
if idx := strings.Index(accumulated, "<api_call>"); idx >= 0 {
|
||||
apiTagSeen = true
|
||||
// Forward only the text that appeared before the tag.
|
||||
if display := strings.TrimSpace(accumulated[:idx]); display != "" {
|
||||
onChunk(display)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Forward only the "safe" prefix — hold back the last safeOffset chars
|
||||
// in case they are the beginning of an <api_call> tag.
|
||||
if safe := len(accumulated) - safeOffset; safe > 0 {
|
||||
onChunk(accumulated[:safe])
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp, err = llm.CallWithRequest(req)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
|
||||
return "AI assistant temporarily unavailable. Please try again."
|
||||
}
|
||||
lastResp = resp
|
||||
|
||||
apiReq, textBefore := parseAPICall(resp)
|
||||
if apiReq == nil {
|
||||
// No api_call tag — LLM gave a final answer (already streamed if onChunk set).
|
||||
reply := stripAPICallTag(strings.TrimSpace(resp))
|
||||
a.memory.Add("user", userMessage)
|
||||
a.memory.Add("assistant", reply)
|
||||
return reply
|
||||
}
|
||||
|
||||
// api_call iteration — reset display to thinking indicator before executing.
|
||||
if onChunk != nil {
|
||||
onChunk("⏳")
|
||||
}
|
||||
|
||||
logger.Infof("Agent: iter=%d %s %s", i+1, apiReq.Method, apiReq.Path)
|
||||
result := a.apiTool.execute(apiReq)
|
||||
|
||||
if textBefore != "" {
|
||||
turnMsgs = append(turnMsgs, mcp.NewAssistantMessage(textBefore))
|
||||
}
|
||||
turnMsgs = append(turnMsgs, mcp.NewUserMessage(
|
||||
fmt.Sprintf("[API result: %s %s]\n%s", apiReq.Method, apiReq.Path, result),
|
||||
))
|
||||
}
|
||||
|
||||
// Safety: max iterations reached — ask LLM for a final summary (non-streaming).
|
||||
logger.Warnf("Agent: max iterations (%d) reached", maxIterations)
|
||||
turnMsgs = append(turnMsgs, mcp.NewUserMessage("Please summarize the results and give the user a final reply."))
|
||||
if finalReq, err := mcp.NewRequestBuilder().
|
||||
WithSystemPrompt(a.systemPrompt).
|
||||
AddConversationHistory(turnMsgs).
|
||||
Build(); err == nil {
|
||||
if finalResp, err := llm.CallWithRequest(finalReq); err == nil {
|
||||
lastResp = finalResp
|
||||
}
|
||||
}
|
||||
|
||||
reply := stripAPICallTag(strings.TrimSpace(lastResp))
|
||||
a.memory.Add("user", userMessage)
|
||||
a.memory.Add("assistant", reply)
|
||||
return reply
|
||||
}
|
||||
|
||||
// stripAPICallTag removes any <api_call>...</api_call> fragment from s.
|
||||
// Used as a defensive layer to ensure tags never leak to the user.
|
||||
func stripAPICallTag(s string) string {
|
||||
if idx := strings.Index(s, "<api_call>"); idx >= 0 {
|
||||
return strings.TrimSpace(s[:idx])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ResetMemory clears conversation history (called on /start).
|
||||
func (a *Agent) ResetMemory() {
|
||||
a.memory.ResetFull()
|
||||
}
|
||||
183
telegram/agent/agent_test.go
Normal file
183
telegram/agent/agent_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
type mockLLM struct {
|
||||
responses []string
|
||||
calls int
|
||||
lastMsgs []mcp.Message
|
||||
}
|
||||
|
||||
func (m *mockLLM) SetAPIKey(_, _, _ string) {}
|
||||
func (m *mockLLM) SetTimeout(_ time.Duration) {}
|
||||
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return m.next() }
|
||||
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
m.lastMsgs = req.Messages
|
||||
return m.next()
|
||||
}
|
||||
func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
|
||||
m.lastMsgs = req.Messages
|
||||
r, err := m.next()
|
||||
if onChunk != nil {
|
||||
onChunk(r)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
func (m *mockLLM) next() (string, error) {
|
||||
if m.calls < len(m.responses) {
|
||||
r := m.responses[m.calls]
|
||||
m.calls++
|
||||
return r, nil
|
||||
}
|
||||
return "OK", nil
|
||||
}
|
||||
|
||||
func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
|
||||
return func() mcp.AIClient { return llm }
|
||||
}
|
||||
|
||||
const testPrompt = "You are a test assistant."
|
||||
|
||||
// TestAgentDirectReply: LLM replies without api_call — one call, direct reply.
|
||||
func TestAgentDirectReply(t *testing.T) {
|
||||
llm := &mockLLM{responses: []string{"Hello! How can I help you?"}}
|
||||
a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("hello", nil)
|
||||
|
||||
if reply != "Hello! How can I help you?" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 1 {
|
||||
t.Fatalf("expected 1 LLM call, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPICall: LLM calls API, gets result, gives final reply — two LLM calls.
|
||||
func TestAgentAPICall(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/my-traders" {
|
||||
w.Write([]byte(`[{"id":"t1","name":"BTC Strategy"}]`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []string{
|
||||
`Let me check.<api_call>{"method":"GET","path":"/api/my-traders","body":{}}</api_call>`,
|
||||
"You have one trader: BTC Strategy.",
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("list my traders", nil)
|
||||
|
||||
if reply != "You have one trader: BTC Strategy." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 2 {
|
||||
t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMultiStep: LLM chains two API calls before final reply — three LLM calls.
|
||||
func TestAgentMultiStep(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []string{
|
||||
`Checking account.<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
|
||||
`Now checking positions.<api_call>{"method":"GET","path":"/api/positions","body":{}}</api_call>`,
|
||||
"Account looks healthy and no open positions.",
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("show me account status", nil)
|
||||
|
||||
if llm.calls != 3 {
|
||||
t.Fatalf("expected 3 LLM calls (2 api + 1 final), got %d", llm.calls)
|
||||
}
|
||||
if reply != "Account looks healthy and no open positions." {
|
||||
t.Fatalf("unexpected final reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPIResultInContext: API result must appear in next LLM message.
|
||||
func TestAgentAPIResultInContext(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"balance":1234.56}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []string{
|
||||
`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
|
||||
"Balance is 1234.56 USDT.",
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
a.Run("show balance", nil)
|
||||
|
||||
found := false
|
||||
for _, msg := range llm.lastMsgs {
|
||||
if strings.Contains(msg.Content, "API result") || strings.Contains(msg.Content, "balance") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("API result not found in subsequent LLM context")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAPICall: unit tests for the XML tag parser.
|
||||
func TestParseAPICall(t *testing.T) {
|
||||
t.Run("valid call", func(t *testing.T) {
|
||||
resp := `Stopping trader.<api_call>{"method":"POST","path":"/api/traders/t1/stop","body":{}}</api_call>`
|
||||
req, text := parseAPICall(resp)
|
||||
if req == nil {
|
||||
t.Fatal("expected api_call, got nil")
|
||||
}
|
||||
if req.Method != "POST" || req.Path != "/api/traders/t1/stop" {
|
||||
t.Fatalf("unexpected req: %+v", req)
|
||||
}
|
||||
if text != "Stopping trader." {
|
||||
t.Fatalf("unexpected text before tag: %q", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no call tag", func(t *testing.T) {
|
||||
req, text := parseAPICall("Just a reply.")
|
||||
if req != nil {
|
||||
t.Fatal("expected nil api_call")
|
||||
}
|
||||
if text != "Just a reply." {
|
||||
t.Fatalf("expected original text, got %q", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON", func(t *testing.T) {
|
||||
req, _ := parseAPICall(`<api_call>NOT JSON</api_call>`)
|
||||
if req != nil {
|
||||
t.Fatal("expected nil for malformed JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
109
telegram/agent/apicall.go
Normal file
109
telegram/agent/apicall.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiCallTool executes HTTP requests against the NOFX API server.
|
||||
// This is the only tool available to the agent.
|
||||
type apiCallTool struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// apiRequest is the parsed structure from the LLM's <api_call> tag.
|
||||
type apiRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
func newAPICallTool(port int, token string) *apiCallTool {
|
||||
return &apiCallTool{
|
||||
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||
token: token,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// execute calls the API and returns the response as a string for LLM consumption.
|
||||
func (t *apiCallTool) execute(req *apiRequest) string {
|
||||
if req.Method == "" || req.Path == "" {
|
||||
return "error: method and path are required"
|
||||
}
|
||||
if !strings.HasPrefix(req.Path, "/") {
|
||||
req.Path = "/" + req.Path
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Method != "GET" && len(req.Body) > 0 {
|
||||
b, err := json.Marshal(req.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error marshaling body: %v", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error creating request: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+t.token)
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("API call failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Pretty-print JSON for better LLM readability
|
||||
var v any
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||
return string(pretty)
|
||||
}
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// parseAPICall extracts <api_call>...</api_call> from LLM response.
|
||||
// Returns (nil, original) if not found or malformed JSON.
|
||||
func parseAPICall(resp string) (*apiRequest, string) {
|
||||
const openTag = "<api_call>"
|
||||
const closeTag = "</api_call>"
|
||||
|
||||
start := strings.Index(resp, openTag)
|
||||
end := strings.Index(resp, closeTag)
|
||||
if start < 0 || end < 0 || end <= start {
|
||||
return nil, resp
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimSpace(resp[start+len(openTag) : end])
|
||||
var req apiRequest
|
||||
if err := json.Unmarshal([]byte(jsonStr), &req); err != nil {
|
||||
logger.Warnf("Agent: failed to parse api_call JSON %q: %v", jsonStr, err)
|
||||
return nil, resp
|
||||
}
|
||||
|
||||
return &req, strings.TrimSpace(resp[:start])
|
||||
}
|
||||
78
telegram/agent/manager.go
Normal file
78
telegram/agent/manager.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager holds one Agent per Telegram chat ID.
|
||||
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
agents map[int64]*Agent
|
||||
lanes map[int64]chan struct{}
|
||||
apiPort int
|
||||
botToken string
|
||||
userID string
|
||||
getLLM func() mcp.AIClient
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
|
||||
// userID is the database user ID the bot authenticates as (used in system prompt context).
|
||||
func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
|
||||
return &Manager{
|
||||
agents: make(map[int64]*Agent),
|
||||
lanes: make(map[int64]chan struct{}),
|
||||
apiPort: apiPort,
|
||||
botToken: botToken,
|
||||
userID: userID,
|
||||
getLLM: getLLM,
|
||||
systemPrompt: BuildAgentPrompt(apiDocs, userID),
|
||||
}
|
||||
}
|
||||
|
||||
// Run processes a message for the given chat ID.
|
||||
// If the same chat is already processing a message, this call blocks until it completes
|
||||
// or the lane wait times out (60 s), whichever comes first.
|
||||
// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming).
|
||||
func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string {
|
||||
a, lane := m.getOrCreate(chatID)
|
||||
select {
|
||||
case lane <- struct{}{}:
|
||||
case <-time.After(60 * time.Second):
|
||||
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
|
||||
return "上一条消息仍在处理中,请稍等片刻后再试。"
|
||||
}
|
||||
defer func() { <-lane }()
|
||||
return a.Run(userMessage, onChunk)
|
||||
}
|
||||
|
||||
// Reset clears memory for the given chat (called on /start).
|
||||
func (m *Manager) Reset(chatID int64) {
|
||||
m.mu.Lock()
|
||||
a, ok := m.agents[chatID]
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
a.ResetMemory()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
a, ok := m.agents[chatID]
|
||||
if !ok {
|
||||
a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt)
|
||||
m.agents[chatID] = a
|
||||
}
|
||||
lane, ok := m.lanes[chatID]
|
||||
if !ok {
|
||||
lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
|
||||
m.lanes[chatID] = lane
|
||||
}
|
||||
return a, lane
|
||||
}
|
||||
107
telegram/agent/prompt.go
Normal file
107
telegram/agent/prompt.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
|
||||
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
|
||||
// userID is the actual database user ID the bot authenticates as.
|
||||
func BuildAgentPrompt(apiDocs, userID string) string {
|
||||
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
|
||||
|
||||
## Your Identity
|
||||
- You are authenticated as user ID: %s
|
||||
- All API calls are made on behalf of this user
|
||||
- When asked "which user / username / email" — answer with this user ID directly, no API call needed
|
||||
|
||||
## Tool: api_call
|
||||
|
||||
Append EXACTLY ONE tag at the very end of your reply when you need to call the API:
|
||||
<api_call>{"method":"GET","path":"/api/xxx","body":{}}</api_call>
|
||||
|
||||
Rules:
|
||||
- The tag must be the LAST thing in your message — nothing after it
|
||||
- NEVER more than one <api_call> tag per response
|
||||
- 【CRITICAL】NEVER say "让我查询..."、"现在获取..."、"I will call..."、"Let me check..." — just ACT silently, no narration at all
|
||||
- method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
- body: JSON object (use {} for GET requests)
|
||||
- query parameters go in the path: /api/positions?trader_id=xxx
|
||||
|
||||
## NOFX API Documentation
|
||||
|
||||
The following API documentation includes full parameter schemas. Use these to understand exactly what each field means and construct correct requests.
|
||||
|
||||
%s
|
||||
|
||||
## Behavior Rules
|
||||
1. 【NO NARRATION】Never tell the user what API you are calling. Zero narration. Just act.
|
||||
2. Only ONE <api_call> tag per response, always at the very end
|
||||
3. After getting an API result, decide: call another API or give a final reply
|
||||
4. If the API returns success (2xx), the operation succeeded — do not retry
|
||||
5. Reply in the same language the user used (中文→中文, English→English)
|
||||
6. Keep replies concise — show results, not process
|
||||
7. Ask for ALL required information in ONE message — never ask one field at a time
|
||||
8. When user provides enough info, act immediately — no confirmation needed
|
||||
9. Be decisive — infer intent from context, use schema to fill in smart defaults
|
||||
|
||||
## Verification Rule (CRITICAL)
|
||||
After ANY PUT or POST that creates or modifies a resource:
|
||||
1. Immediately GET the resource to read actual saved values
|
||||
2. Show the user the KEY fields they care about from the GET response
|
||||
3. NEVER just say "updated successfully" without showing the actual values
|
||||
4. If saved values look wrong, correct them automatically
|
||||
|
||||
## Error Handling
|
||||
- 400: explain what was wrong, ask user to correct
|
||||
- 404: resource doesn't exist, check IDs
|
||||
- "AI model not enabled": tell user to enable the model first via PUT /api/models
|
||||
- "Exchange not enabled": tell user to enable the exchange first
|
||||
- 5xx: server error, ask user to try again
|
||||
- stream interrupted / unavailable: apologize briefly and ask user to retry
|
||||
|
||||
## How to Use the API Schema
|
||||
All API knowledge comes from the documentation above. Use field descriptions to:
|
||||
- Know exactly which fields are required vs optional
|
||||
- Understand semantics and build correct request bodies from natural language
|
||||
- For StrategyConfig: intelligently fill all fields based on user's trading style
|
||||
|
||||
## Account State (injected at conversation start)
|
||||
At the start of each new conversation, a [Current Account State] block is provided with:
|
||||
- AI Models: all configured models with their IDs and enabled status
|
||||
- Exchanges: all configured exchanges with their IDs and enabled status
|
||||
- Strategies: all existing strategies with their IDs
|
||||
- Traders: all existing traders with their IDs and running status
|
||||
|
||||
Use this to:
|
||||
- NEVER ask for exchange/model info that is already configured — use the existing IDs directly
|
||||
- Know instantly if the user has 0 or N resources of each type
|
||||
- If only one exchange/model exists and user doesn't specify, use it directly without asking
|
||||
- If multiple exist, list them and ask which one to use
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Configure model**: Ask only for api_key. Set enabled:true, send empty strings for URL/model (backend applies provider defaults).
|
||||
|
||||
**Configure exchange**: Ask for all required fields in ONE message (see schema). Always set enabled:true.
|
||||
|
||||
**Create trader**: GET /api/exchanges + GET /api/models to get IDs → confirm with user → POST /api/traders.
|
||||
|
||||
**Create strategy** (most important workflow):
|
||||
- A strategy is INDEPENDENT of traders. Never GET trader info just to create a strategy.
|
||||
- If user specifies style + coins (e.g. "BTC trend"), build and POST immediately — no questions needed.
|
||||
- Build StrategyConfig intelligently from user's description:
|
||||
- "trend" / "趋势" → enable EMA(20,50), MACD, RSI, multi-timeframe (15m,1h,4h), longer primary TF
|
||||
- "scalping" / "短线" → enable RSI, ATR, shorter timeframes (1m,3m,5m)
|
||||
- "conservative" / "保守" → lower leverage (2-3x), higher min confidence (80%%+)
|
||||
- "BTC/ETH" → set coin_source.source_type="static", static_coins=["BTC/USDT"] or similar
|
||||
- After POST: GET /api/strategies/:id to verify → show user: name, coins, key indicators, leverage
|
||||
|
||||
**Update strategy config**:
|
||||
1. GET /api/strategies/:id to read current full config
|
||||
2. Modify only what user asked (keep all other fields)
|
||||
3. PUT /api/strategies/:id with complete merged config
|
||||
4. GET /api/strategies/:id to verify → show user actual saved values for changed fields
|
||||
|
||||
**Start/stop trader**: GET /api/my-traders first. If only one trader, act directly. If multiple, list and ask.
|
||||
|
||||
**Query data**: GET /api/my-traders to get trader_id, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userID, apiDocs)
|
||||
}
|
||||
310
telegram/bot.go
Normal file
310
telegram/bot.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"nofx/api"
|
||||
"nofx/config"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram/agent"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// Start initializes and runs the Telegram bot in a blocking supervisor loop.
|
||||
// Supports hot-reload: when a signal is sent on reloadCh, the bot restarts
|
||||
// with the latest token (re-read from DB or env). Must be called as a goroutine from main.go.
|
||||
// Deployment note: uses long-polling (not webhook) — safe for private networks,
|
||||
// no inbound ports required.
|
||||
func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
|
||||
for {
|
||||
token := resolveToken(cfg, st)
|
||||
if token == "" {
|
||||
logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...")
|
||||
// Block until a reload signal arrives, then re-check for a token.
|
||||
<-reloadCh
|
||||
continue
|
||||
}
|
||||
|
||||
stopped := runBot(token, cfg, st)
|
||||
if !stopped {
|
||||
// Bot exited with an unrecoverable error; do not restart automatically.
|
||||
return
|
||||
}
|
||||
|
||||
// Bot was stopped cleanly. Wait for a reload signal before restarting.
|
||||
select {
|
||||
case <-reloadCh:
|
||||
logger.Info("Reloading Telegram bot with new token...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveToken returns the bot token, preferring the DB-stored value over the env/config value.
|
||||
func resolveToken(cfg *config.Config, st *store.Store) string {
|
||||
dbCfg, err := st.TelegramConfig().Get()
|
||||
if err == nil && dbCfg.BotToken != "" {
|
||||
return dbCfg.BotToken
|
||||
}
|
||||
return cfg.TelegramBotToken
|
||||
}
|
||||
|
||||
// runBot runs the bot until StopReceivingUpdates is called (clean stop → true)
|
||||
// or a fatal error occurs (false).
|
||||
func runBot(token string, cfg *config.Config, st *store.Store) bool {
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
logger.Errorf("Telegram bot failed to start: %v", err)
|
||||
return false
|
||||
}
|
||||
logger.Infof("Telegram bot @%s started (long-polling mode)", bot.Self.UserName)
|
||||
|
||||
// Determine allowed chat ID:
|
||||
// Priority 1: env var TELEGRAM_ADMIN_CHAT_ID (explicit)
|
||||
// Priority 2: DB-stored bound chat ID (set by /start)
|
||||
// Priority 3: 0 = no binding yet, first /start will bind
|
||||
allowedChatID := cfg.TelegramAdminChatID
|
||||
if allowedChatID == 0 {
|
||||
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
|
||||
allowedChatID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the real user ID: use the first registered user so that bot-made
|
||||
// changes (model/exchange configs) are visible in the frontend under that user.
|
||||
// Falls back to "default" if no users exist yet (fresh install).
|
||||
botUserID := "default"
|
||||
if ids, err := st.User().GetAllIDs(); err == nil && len(ids) > 0 {
|
||||
botUserID = ids[0]
|
||||
}
|
||||
|
||||
// Generate a bot JWT for authenticated API calls. Re-generated on each bot start.
|
||||
botToken, err := agent.GenerateBotToken(botUserID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to generate bot JWT: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Wire the AI agent manager. API docs are auto-generated from registered routes.
|
||||
agents := agent.NewManager(cfg.APIServerPort, botToken, botUserID,
|
||||
func() mcp.AIClient { return newLLMClient(st) },
|
||||
api.GetAPIDocs(),
|
||||
)
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
for update := range updates {
|
||||
if update.Message == nil {
|
||||
continue
|
||||
}
|
||||
chatID := update.Message.Chat.ID
|
||||
text := update.Message.Text
|
||||
|
||||
// Handle /start: auto-bind or welcome
|
||||
if text == "/start" {
|
||||
if allowedChatID == 0 {
|
||||
// First user to /start becomes the bound admin
|
||||
username := update.Message.From.UserName
|
||||
if err := st.TelegramConfig().BindUser(chatID, "@"+username); err != nil {
|
||||
logger.Errorf("Failed to bind Telegram user: %v", err)
|
||||
sendMsg(bot, chatID, "Binding failed, please check server logs.")
|
||||
continue
|
||||
}
|
||||
allowedChatID = chatID
|
||||
logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID)
|
||||
sendMsg(bot, chatID, "Bound successfully! "+welcomeMessage())
|
||||
} else if chatID == allowedChatID {
|
||||
// Already bound, same user: reset session and show welcome
|
||||
agents.Reset(chatID)
|
||||
sendMsg(bot, chatID, welcomeMessage())
|
||||
} else {
|
||||
sendMsg(bot, chatID, "This bot is already bound to another user.")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle /help
|
||||
if text == "/help" {
|
||||
sendMsg(bot, chatID, helpMessage())
|
||||
continue
|
||||
}
|
||||
|
||||
// Access control
|
||||
if allowedChatID != 0 && chatID != allowedChatID {
|
||||
sendMsg(bot, chatID, "Unauthorized access.")
|
||||
continue
|
||||
}
|
||||
if allowedChatID == 0 {
|
||||
sendMsg(bot, chatID, "Please send /start to bind your account first.")
|
||||
continue
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send a placeholder immediately, then stream-edit as reply arrives.
|
||||
go func(chatID int64, text string) {
|
||||
// Send ⏳ placeholder so the user sees an instant response.
|
||||
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
|
||||
placeholderID := 0
|
||||
if err == nil {
|
||||
placeholderID = sent.MessageID
|
||||
}
|
||||
|
||||
// Rate-limited edit helper: edits the placeholder at most once per second.
|
||||
// Exception: "⏳" thinking-indicator resets always go through immediately
|
||||
// so the user always sees the state change between agent iterations.
|
||||
var (
|
||||
mu sync.Mutex
|
||||
lastEdit time.Time
|
||||
)
|
||||
onChunk := func(accumulated string) {
|
||||
if placeholderID == 0 {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
isThinking := accumulated == "⏳"
|
||||
if !isThinking && time.Since(lastEdit) < time.Second {
|
||||
return
|
||||
}
|
||||
lastEdit = time.Now()
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, accumulated)
|
||||
bot.Send(edit) //nolint:errcheck
|
||||
}
|
||||
|
||||
reply := agents.Run(chatID, text, onChunk)
|
||||
|
||||
// Final edit: use Markdown, fall back to plain text on parse error.
|
||||
if placeholderID != 0 {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
edit.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(edit); err != nil {
|
||||
edit2 := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
bot.Send(edit2) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
msg := tgbotapi.NewMessage(chatID, reply)
|
||||
msg.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
msg.ParseMode = ""
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}(chatID, text)
|
||||
}
|
||||
|
||||
// updates channel was closed — bot stopped cleanly
|
||||
return true
|
||||
}
|
||||
|
||||
func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
// newLLMClient builds an LLM client for the agent.
|
||||
// Priority: DB-configured model (Web UI) > environment variables.
|
||||
// Uses provider-specific constructors to ensure correct default base URLs and models.
|
||||
func newLLMClient(st *store.Store) mcp.AIClient {
|
||||
// 1. Try any enabled model from DB (user configured via Web UI, any user_id)
|
||||
if model, err := st.AIModel().GetAnyEnabled(); err == nil {
|
||||
apiKey := string(model.APIKey)
|
||||
if apiKey != "" {
|
||||
client := clientForProvider(model.Provider)
|
||||
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
logger.Infof("Telegram agent: provider=%s user_id=%s model=%q url=%q",
|
||||
model.Provider, model.UserID, model.CustomModelName, model.CustomAPIURL)
|
||||
return client
|
||||
}
|
||||
logger.Warnf("Telegram: DB model found (provider=%s) but API key is empty after decryption", model.Provider)
|
||||
} else {
|
||||
logger.Warnf("Telegram: no enabled model in DB (%v), trying env vars", err)
|
||||
}
|
||||
|
||||
// 2. Fall back to environment variables
|
||||
for _, pair := range []struct{ provider, key, url string }{
|
||||
{"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL},
|
||||
{"openai", os.Getenv("OPENAI_API_KEY"), ""},
|
||||
{"claude", os.Getenv("ANTHROPIC_API_KEY"), ""},
|
||||
} {
|
||||
if pair.key != "" {
|
||||
client := clientForProvider(pair.provider)
|
||||
client.SetAPIKey(pair.key, pair.url, "")
|
||||
logger.Infof("Telegram agent: using %s from env var", pair.provider)
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warn("Telegram: no AI key found in DB or env — agent will fail. Configure a model in the Web UI.")
|
||||
return mcp.NewDeepSeekClient() // return a typed client so caller gets a clear API error
|
||||
}
|
||||
|
||||
// clientForProvider returns the appropriate provider-specific client.
|
||||
// Each constructor sets correct default base URL and model for that provider.
|
||||
func clientForProvider(provider string) mcp.AIClient {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return mcp.NewOpenAIClient()
|
||||
case "deepseek":
|
||||
return mcp.NewDeepSeekClient()
|
||||
default:
|
||||
// Qwen, Kimi, Grok, Gemini, Claude, custom: fall back to DeepSeek-format client.
|
||||
// These providers use OpenAI-compatible APIs; CustomAPIURL and CustomModelName are required.
|
||||
return mcp.NewDeepSeekClient()
|
||||
}
|
||||
}
|
||||
|
||||
func welcomeMessage() string {
|
||||
return `*NOFX Trading Assistant Connected!*
|
||||
|
||||
You can manage your trading system with natural language:
|
||||
|
||||
*Query*
|
||||
- Show current positions
|
||||
- Show account balance
|
||||
|
||||
*Control*
|
||||
- Start trader
|
||||
- Stop trader
|
||||
|
||||
*Configure*
|
||||
- Create a BTC strategy with 8% stop loss
|
||||
- Configure Binance exchange API
|
||||
- Add DeepSeek AI model
|
||||
- Update strategy prompt
|
||||
|
||||
Send /help for detailed help
|
||||
Send /start to reset session`
|
||||
}
|
||||
|
||||
func helpMessage() string {
|
||||
return `*NOFX Trading Assistant Guide*
|
||||
|
||||
*Query examples:*
|
||||
- "Show current positions"
|
||||
- "Show account balance"
|
||||
- "List my traders"
|
||||
|
||||
*Control examples:*
|
||||
- "Start trader"
|
||||
- "Stop trader [name]"
|
||||
|
||||
*Configure examples:*
|
||||
- "Create a BTC strategy with RSI+MACD, 8% stop loss, 20% max position"
|
||||
- "Configure Binance exchange, API Key is xxx, Secret is xxx"
|
||||
- "Add DeepSeek model, Key is xxx"
|
||||
- "Update strategy prompt for my main strategy to: you are a conservative trader..."
|
||||
|
||||
*Other commands:*
|
||||
- /start - Reset current session
|
||||
- /help - Show this help
|
||||
|
||||
You can use natural language — no need to memorize specific command formats.`
|
||||
}
|
||||
105
telegram/session/memory.go
Normal file
105
telegram/session/memory.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/mcp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
compactionThresholdTokens = 3000
|
||||
charsPerToken = 3 // rough estimate for token counting
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role string // "user" or "assistant"
|
||||
Content string
|
||||
}
|
||||
|
||||
// Memory manages conversation history with automatic compaction.
|
||||
// Inspired by openclaw's compaction pattern:
|
||||
// when ShortTerm exceeds threshold, LLM silently summarizes it into LongTerm.
|
||||
type Memory struct {
|
||||
LongTerm string // Durable summary (survives compaction, user never sees this happen)
|
||||
ShortTerm []Message // Recent conversation (cleared on compaction)
|
||||
llm mcp.AIClient
|
||||
}
|
||||
|
||||
func NewMemory(llm mcp.AIClient) *Memory {
|
||||
return &Memory{llm: llm}
|
||||
}
|
||||
|
||||
// Add appends a message and triggers compaction if threshold exceeded
|
||||
func (m *Memory) Add(role, content string) {
|
||||
m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content})
|
||||
if m.estimateTokens() > compactionThresholdTokens {
|
||||
m.compact()
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext returns context string for the agent's conversation history.
|
||||
func (m *Memory) BuildContext() string {
|
||||
var sb strings.Builder
|
||||
if m.LongTerm != "" {
|
||||
sb.WriteString("[Summary of earlier conversation]\n")
|
||||
sb.WriteString(m.LongTerm)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
if len(m.ShortTerm) > 0 {
|
||||
sb.WriteString("[Recent conversation]\n")
|
||||
for _, msg := range m.ShortTerm {
|
||||
sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Reset clears short-term history (LongTerm preserved intentionally)
|
||||
func (m *Memory) Reset() {
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
|
||||
// ResetFull clears everything including long-term memory
|
||||
func (m *Memory) ResetFull() {
|
||||
m.ShortTerm = []Message{}
|
||||
m.LongTerm = ""
|
||||
}
|
||||
|
||||
func (m *Memory) estimateTokens() int {
|
||||
total := len(m.LongTerm)
|
||||
for _, msg := range m.ShortTerm {
|
||||
total += len(msg.Content)
|
||||
}
|
||||
return total / charsPerToken
|
||||
}
|
||||
|
||||
// compact summarizes short-term history into long-term memory.
|
||||
// This runs silently - the user never sees it happen.
|
||||
// If LLM call fails, short-term is preserved as-is (no data loss).
|
||||
func (m *Memory) compact() {
|
||||
if m.llm == nil || len(m.ShortTerm) == 0 {
|
||||
return
|
||||
}
|
||||
history := m.BuildContext()
|
||||
systemPrompt := `You are a conversation summarizer. Compress the following trading assistant conversation into a concise summary.
|
||||
|
||||
Must preserve:
|
||||
- What the user is configuring (strategy/exchange/model/trader)
|
||||
- Confirmed parameters (trading pairs, leverage, stop loss, indicators, etc.)
|
||||
- Pending or missing parameters
|
||||
- User preferences and requirements
|
||||
|
||||
Output: plain text summary, under 200 words.`
|
||||
|
||||
summary, err := m.llm.CallWithMessages(systemPrompt, history)
|
||||
if err != nil {
|
||||
// Compaction failed: keep short-term as-is, never lose user data
|
||||
return
|
||||
}
|
||||
if m.LongTerm != "" {
|
||||
m.LongTerm = m.LongTerm + "\n" + summary
|
||||
} else {
|
||||
m.LongTerm = summary
|
||||
}
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { getModelIcon } from './ModelIcons'
|
||||
import { TraderConfigModal } from './TraderConfigModal'
|
||||
import { DeepVoidBackground } from './DeepVoidBackground'
|
||||
import { ExchangeConfigModal } from './traders/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from './traders/TelegramConfigModal'
|
||||
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
||||
import {
|
||||
Bot,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import { confirmToast } from '../lib/notify'
|
||||
import { toast } from 'sonner'
|
||||
@@ -148,6 +150,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showModelModal, setShowModelModal] = useState(false)
|
||||
const [showExchangeModal, setShowExchangeModal] = useState(false)
|
||||
const [showTelegramModal, setShowTelegramModal] = useState(false)
|
||||
const [editingModel, setEditingModel] = useState<string | null>(null)
|
||||
const [editingExchange, setEditingExchange] = useState<string | null>(null)
|
||||
const [editingTrader, setEditingTrader] = useState<any>(null)
|
||||
@@ -849,6 +852,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowTelegramModal(true)}
|
||||
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider transition-all border border-sky-900/50 bg-black/20 text-sky-500 hover:text-sky-300 hover:border-sky-700 whitespace-nowrap backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
<span>TELEGRAM_BOT</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
|
||||
@@ -1379,6 +1392,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Telegram Bot Modal */}
|
||||
{showTelegramModal && (
|
||||
<TelegramConfigModal
|
||||
onClose={() => setShowTelegramModal(false)}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
|
||||
@@ -336,9 +336,9 @@ export function TraderConfigModal({
|
||||
<option value="">{t('noStrategyManual', language)}</option>
|
||||
{strategies.map((strategy) => (
|
||||
<option key={strategy.id} value={strategy.id}>
|
||||
{selectedStrategy.name}
|
||||
{selectedStrategy.is_active ? t('active', language) : ''}
|
||||
{selectedStrategy.is_default ? t('default', language) : ''}
|
||||
{strategy.name}
|
||||
{strategy.is_active ? t('strategyActive', language) : ''}
|
||||
{strategy.is_default ? t('strategyDefault', language) : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
530
web/src/components/traders/TelegramConfigModal.tsx
Normal file
530
web/src/components/traders/TelegramConfigModal.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Check, ChevronLeft, ExternalLink, MessageCircle, Unlink, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '../../lib/api'
|
||||
import type { TelegramConfig, AIModel } from '../../types'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
|
||||
// Step indicator (reused pattern from ExchangeConfigModal)
|
||||
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{labels.map((label, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
|
||||
style={{
|
||||
background: index < currentStep ? '#0ECB81' : index === currentStep ? '#2AABEE' : '#2B3139',
|
||||
color: index <= currentStep ? '#000' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{index < currentStep ? <Check className="w-4 h-4" /> : index + 1}
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium hidden sm:block"
|
||||
style={{ color: index === currentStep ? '#EAECEF' : '#848E9C' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{index < labels.length - 1 && (
|
||||
<div
|
||||
className="w-8 h-0.5 mx-1"
|
||||
style={{ background: index < currentStep ? '#0ECB81' : '#2B3139' }}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TelegramConfigModalProps {
|
||||
onClose: () => void
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function TelegramConfigModal({ onClose, language }: TelegramConfigModalProps) {
|
||||
const [step, setStep] = useState(0)
|
||||
const [token, setToken] = useState('')
|
||||
const [selectedModelId, setSelectedModelId] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [config, setConfig] = useState<TelegramConfig | null>(null)
|
||||
const [models, setModels] = useState<AIModel[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isUnbinding, setIsUnbinding] = useState(false)
|
||||
|
||||
const zh = language === 'zh'
|
||||
|
||||
// Load current config and available models
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.getTelegramConfig().catch(() => null),
|
||||
api.getModelConfigs().catch(() => [] as AIModel[]),
|
||||
]).then(([cfg, allModels]) => {
|
||||
const enabledModels = allModels.filter((m) => m.enabled)
|
||||
setModels(enabledModels)
|
||||
|
||||
if (cfg) {
|
||||
setConfig(cfg)
|
||||
setSelectedModelId(cfg.model_id ?? '')
|
||||
if (cfg.is_bound) {
|
||||
setStep(2)
|
||||
} else if (cfg.token_masked && cfg.token_masked !== '') {
|
||||
setStep(1)
|
||||
}
|
||||
}
|
||||
}).finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSaveToken = async () => {
|
||||
if (!token.trim()) return
|
||||
if (isSaving) return
|
||||
|
||||
// Basic format validation: looks like "123456789:ABCdef..."
|
||||
if (!/^\d+:[A-Za-z0-9_-]{35,}$/.test(token.trim())) {
|
||||
toast.error(zh ? 'Bot Token 格式不正确,应为 "数字:字母数字串"' : 'Invalid Bot Token format. Expected "numbers:alphanumeric"')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await api.updateTelegramConfig(token.trim(), selectedModelId || undefined)
|
||||
toast.success(zh ? 'Bot Token 已保存,等待绑定' : 'Bot Token saved, waiting for binding')
|
||||
const updated = await api.getTelegramConfig()
|
||||
setConfig(updated)
|
||||
setToken('')
|
||||
setStep(1)
|
||||
} catch (err) {
|
||||
toast.error(zh ? '保存失败,请检查 Token 是否正确' : 'Save failed, please verify the token')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnbind = async () => {
|
||||
if (isUnbinding) return
|
||||
setIsUnbinding(true)
|
||||
try {
|
||||
await api.unbindTelegram()
|
||||
toast.success(zh ? '已解绑 Telegram 账号' : 'Telegram account unbound')
|
||||
const updated = await api.getTelegramConfig()
|
||||
setConfig(updated)
|
||||
setStep(updated.token_masked ? 1 : 0)
|
||||
} catch {
|
||||
toast.error(zh ? '解绑失败' : 'Unbind failed')
|
||||
} finally {
|
||||
setIsUnbinding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stepLabels = zh
|
||||
? ['创建 Bot', '绑定账号', '完成']
|
||||
: ['Create Bot', 'Bind Account', 'Done']
|
||||
|
||||
// Model selector shared between steps
|
||||
const ModelSelector = () => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? '选择 AI 模型(可选)' : 'Select AI Model (optional)'}
|
||||
</label>
|
||||
{models.length === 0 ? (
|
||||
<div
|
||||
className="px-4 py-3 rounded-xl text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{zh ? '暂无启用的模型,请先在「AI 模型」中配置' : 'No enabled models. Configure one in AI Models first.'}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: selectedModelId ? '#EAECEF' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<option value="">{zh ? '— 自动选择(推荐)' : '— Auto-select (recommended)'}</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.provider}{m.customModelName ? ` · ${m.customModelName}` : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{zh
|
||||
? '不选则自动使用已启用的模型'
|
||||
: 'Leave blank to auto-use any enabled model'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
|
||||
<div
|
||||
className="rounded-2xl w-full max-w-lg relative my-8 shadow-2xl"
|
||||
style={{ background: 'linear-gradient(180deg, #1E2329 0%, #181A20 100%)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{step > 0 && !config?.is_bound && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(step - 1)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" style={{ color: '#848E9C' }} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-6 h-6" style={{ color: '#2AABEE' }} />
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? 'Telegram Bot 配置' : 'Telegram Bot Setup'}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="px-6 pt-4">
|
||||
<StepIndicator currentStep={step} labels={stepLabels} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 space-y-5">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm font-mono">
|
||||
{zh ? '加载中...' : 'Loading...'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step 0: Create bot via BotFather */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className="p-4 rounded-xl space-y-3"
|
||||
style={{ background: 'rgba(42, 171, 238, 0.1)', border: '1px solid rgba(42, 171, 238, 0.3)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<div className="font-semibold mb-1" style={{ color: '#2AABEE' }}>
|
||||
{zh ? '第一步:在 Telegram 创建你的 Bot' : 'Step 1: Create your Bot in Telegram'}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>1. {zh ? '打开 Telegram,搜索' : 'Open Telegram, search for'} <code className="text-blue-400">@BotFather</code></div>
|
||||
<div>2. {zh ? '发送' : 'Send'} <code className="text-blue-400">/newbot</code> {zh ? '命令' : 'command'}</div>
|
||||
<div>3. {zh ? '按提示输入 Bot 名称和用户名' : 'Follow prompts to set bot name and username'}</div>
|
||||
<div>4. {zh ? 'BotFather 会返回一个 Token,复制它' : 'BotFather will return a Token, copy it'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://t.me/BotFather"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold transition-all hover:scale-[1.02]"
|
||||
style={{ background: '#2AABEE', color: '#000' }}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{zh ? '打开 @BotFather' : 'Open @BotFather'}
|
||||
</a>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? '粘贴 Bot Token' : 'Paste Bot Token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="123456789:ABCdefGHIjklmNOPQRstuvwxYZ"
|
||||
className="w-full px-4 py-3 rounded-xl font-mono text-sm"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{zh ? 'Token 格式:数字:字母数字串,如 123456789:ABCdef...' : 'Format: numbers:alphanumeric, e.g. 123456789:ABCdef...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelSelector />
|
||||
|
||||
<button
|
||||
onClick={handleSaveToken}
|
||||
disabled={isSaving || !token.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: '#2AABEE', color: '#000' }}
|
||||
>
|
||||
{isSaving
|
||||
? (zh ? '保存中...' : 'Saving...')
|
||||
: (<>{zh ? '保存并继续' : 'Save & Continue'} <ArrowRight className="w-4 h-4" /></>)
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Send /start to activate */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className="p-4 rounded-xl space-y-3"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">📱</span>
|
||||
<div>
|
||||
<div className="font-semibold mb-1" style={{ color: '#0ECB81' }}>
|
||||
{zh ? '第二步:向你的 Bot 发送 /start' : 'Step 2: Send /start to your Bot'}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>1. {zh ? '在 Telegram 中搜索你刚创建的 Bot' : 'Search for your newly created Bot in Telegram'}</div>
|
||||
<div>2. {zh ? '点击 Start 或发送' : 'Click Start or send'} <code className="text-green-400">/start</code></div>
|
||||
<div>3. {zh ? 'Bot 会自动绑定到你的账号' : 'Bot will automatically bind to your account'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config?.token_masked && (
|
||||
<div
|
||||
className="p-3 rounded-xl flex items-center gap-3"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-xs font-mono" style={{ color: '#848E9C' }}>
|
||||
{zh ? '当前 Token' : 'Current Token'}
|
||||
</div>
|
||||
<div className="text-sm font-mono" style={{ color: '#EAECEF' }}>
|
||||
{config.token_masked}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="p-3 rounded-xl text-center"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.08)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
>
|
||||
<div className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{zh
|
||||
? '⏳ 等待你发送 /start... 发送后刷新页面查看状态'
|
||||
: '⏳ Waiting for you to send /start... Refresh page after sending'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setStep(0); setToken('') }}
|
||||
className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{zh ? '重新配置 Token' : 'Reconfigure Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const updated = await api.getTelegramConfig()
|
||||
setConfig(updated)
|
||||
if (updated.is_bound) {
|
||||
setStep(2)
|
||||
toast.success(zh ? '绑定成功!' : 'Bound successfully!')
|
||||
} else {
|
||||
toast.info(zh ? '尚未收到 /start,请先向 Bot 发送 /start' : 'No /start received yet. Please send /start to your Bot first')
|
||||
}
|
||||
} catch {
|
||||
toast.error(zh ? '检查失败' : 'Check failed')
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02]"
|
||||
style={{ background: '#0ECB81', color: '#000' }}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{zh ? '检查绑定状态' : 'Check Status'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Bound & active */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className="p-5 rounded-xl text-center space-y-3"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)' }}
|
||||
>
|
||||
<div className="text-4xl">🎉</div>
|
||||
<div className="font-bold text-lg" style={{ color: '#0ECB81' }}>
|
||||
{zh ? 'Telegram Bot 已绑定!' : 'Telegram Bot is Active!'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{zh
|
||||
? '你现在可以通过 Telegram 用自然语言控制交易系统'
|
||||
: 'You can now control the trading system via natural language in Telegram'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config?.token_masked && (
|
||||
<div
|
||||
className="p-3 rounded-xl flex items-center gap-3"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-mono" style={{ color: '#848E9C' }}>
|
||||
{zh ? 'Bot Token' : 'Bot Token'}
|
||||
</div>
|
||||
<div className="text-sm font-mono truncate" style={{ color: '#EAECEF' }}>
|
||||
{config.token_masked}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Model selector — works on active bot */}
|
||||
<BoundModelSelector
|
||||
zh={zh}
|
||||
models={models}
|
||||
currentModelId={config?.model_id ?? ''}
|
||||
onSaved={(modelId) => {
|
||||
setConfig((prev) => prev ? { ...prev, model_id: modelId } : prev)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* What you can do */}
|
||||
<div
|
||||
className="p-4 rounded-xl space-y-2"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#848E9C' }}>
|
||||
{zh ? '支持的命令' : 'Supported Commands'}
|
||||
</div>
|
||||
{[
|
||||
{ cmd: '/help', desc: zh ? '查看所有命令' : 'Show all commands' },
|
||||
{ cmd: zh ? '查看交易员状态' : 'Show trader status', desc: zh ? '自然语言查询' : 'Natural language' },
|
||||
{ cmd: zh ? '启动/停止交易员' : 'Start/stop trader', desc: zh ? '自然语言控制' : 'Natural language control' },
|
||||
{ cmd: zh ? '查看持仓' : 'View positions', desc: zh ? '实时持仓查询' : 'Real-time position query' },
|
||||
{ cmd: zh ? '配置策略' : 'Configure strategy', desc: zh ? '修改交易策略' : 'Modify trading strategy' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<code className="font-mono px-1.5 py-0.5 rounded flex-shrink-0" style={{ background: '#1E2329', color: '#2AABEE' }}>
|
||||
{item.cmd}
|
||||
</code>
|
||||
<span style={{ color: '#848E9C' }}>{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleUnbind}
|
||||
disabled={isUnbinding}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5 disabled:opacity-50"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }}
|
||||
>
|
||||
<Unlink className="w-4 h-4" />
|
||||
{isUnbinding ? (zh ? '解绑中...' : 'Unbinding...') : (zh ? '解绑账号' : 'Unbind Account')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02]"
|
||||
style={{ background: '#2AABEE', color: '#000' }}
|
||||
>
|
||||
{zh ? '完成' : 'Done'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// BoundModelSelector — lets the user change the AI model when the bot is already active.
|
||||
// It updates the model_id without requiring re-entry of the bot token.
|
||||
function BoundModelSelector({
|
||||
zh,
|
||||
models,
|
||||
currentModelId,
|
||||
onSaved,
|
||||
}: {
|
||||
zh: boolean
|
||||
models: AIModel[]
|
||||
currentModelId: string
|
||||
onSaved: (modelId: string) => void
|
||||
}) {
|
||||
const [modelId, setModelId] = useState(currentModelId)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Keep in sync if parent updates
|
||||
useEffect(() => { setModelId(currentModelId) }, [currentModelId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// POST /api/telegram/model — lightweight endpoint for model-only update
|
||||
await api.updateTelegramModel(modelId)
|
||||
onSaved(modelId)
|
||||
toast.success(zh ? 'AI 模型已更新' : 'AI model updated')
|
||||
} catch {
|
||||
toast.error(zh ? '更新失败' : 'Update failed')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (models.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{zh ? 'AI 模型(用于自然语言解析)' : 'AI Model (for natural language)'}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: modelId ? '#EAECEF' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<option value="">{zh ? '— 自动选择' : '— Auto-select'}</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}{m.customModelName ? ` · ${m.customModelName}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || modelId === currentModelId}
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ background: '#F0B90B', color: '#000', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isSaving ? '...' : (zh ? '保存' : 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
TraderConfigData,
|
||||
AIModel,
|
||||
Exchange,
|
||||
TelegramConfig,
|
||||
CreateTraderRequest,
|
||||
CreateExchangeRequest,
|
||||
UpdateModelConfigRequest,
|
||||
@@ -785,4 +786,26 @@ export const api = {
|
||||
if (!result.success) throw new Error('获取历史仓位失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// Telegram Bot API
|
||||
async getTelegramConfig(): Promise<TelegramConfig> {
|
||||
const result = await httpClient.get<TelegramConfig>(`${API_BASE}/telegram`)
|
||||
if (!result.success) throw new Error('获取Telegram配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateTelegramConfig(token: string, modelId?: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/telegram`, { bot_token: token, model_id: modelId ?? '' })
|
||||
if (!result.success) throw new Error('保存Telegram配置失败')
|
||||
},
|
||||
|
||||
async unbindTelegram(): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/telegram/binding`)
|
||||
if (!result.success) throw new Error('解绑Telegram失败')
|
||||
},
|
||||
|
||||
async updateTelegramModel(modelId: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/telegram/model`, { model_id: modelId })
|
||||
if (!result.success) throw new Error('更新Telegram模型失败')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user