diff --git a/.env.example b/.env.example index 5eafa687..22cd7ef2 100644 --- a/.env.example +++ b/.env.example @@ -52,10 +52,6 @@ TRANSPORT_ENCRYPTION=false # Optional: External Services # =========================================== -# Telegram notifications (optional) -# TELEGRAM_BOT_TOKEN=your-bot-token -# TELEGRAM_CHAT_ID=your-chat-id - DB_TYPE=postgres DB_HOST=10. DB_PORT=5432 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e70c2868..eb418964 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,100 +1,50 @@ -# Pull Request +## Summary -> **📋 Choose Specialized Template** -> -> We now offer specialized templates for different types of PRs to help you fill out the information faster: -> -> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes -> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes -> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates -> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes -> -> **How to use?** -> - When creating a PR, add `?template=backend.md` or other template name to the URL -> - Or simply copy and paste the content from the corresponding template +- Problem: +- What changed: +- What did NOT change (scope boundary): ---- +## Change Type -> **💡 Tip:** Recommended PR title format `type(scope): description` -> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue` +- [ ] Bug fix +- [ ] Feature +- [ ] Refactoring +- [ ] Docs +- [ ] Security fix +- [ ] Chore / infra ---- +## Scope -## 📝 Description +- [ ] Trading engine / strategies +- [ ] MCP / AI clients +- [ ] API / server +- [ ] Telegram bot / agent +- [ ] Web UI / frontend +- [ ] Config / deployment +- [ ] CI/CD / infra - - - ---- - -## 🎯 Type of Change - -- [ ] 🐛 Bug fix -- [ ] ✨ New feature -- [ ] 💥 Breaking change -- [ ] 📝 Documentation update -- [ ] 🎨 Code style update -- [ ] ♻️ Refactoring -- [ ] ⚡ Performance improvement -- [ ] ✅ Test update -- [ ] 🔧 Build/config change -- [ ] 🔒 Security fix - ---- - -## 🔗 Related Issues +## Linked Issues - Closes # -- Related to # +- Related # ---- +## Testing -## 📋 Changes Made +What you verified and how: - -- -- +- [ ] `go build ./...` passes +- [ ] `go test ./...` passes +- [ ] Manual testing done (describe below) ---- +## Security Impact -## 🧪 Testing +- Secrets/keys handling changed? (`Yes/No`) +- New/changed API endpoints? (`Yes/No`) +- User input validation affected? (`Yes/No`) -- [ ] Tested locally -- [ ] Tests pass -- [ ] Verified no existing functionality broke +## Compatibility ---- - -## ✅ Checklist - -### Code Quality -- [ ] Code follows project style -- [ ] Self-review completed -- [ ] Comments added for complex logic - -### Documentation -- [ ] Updated relevant documentation - -### Git -- [ ] Commits follow conventional format -- [ ] Rebased on latest `dev` branch -- [ ] No merge conflicts - ---- - -## 📚 Additional Notes - - - - ---- - -**By submitting this PR, I confirm:** - -- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) -- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) -- [ ] My contribution is licensed under AGPL-3.0 - ---- - -🌟 **Thank you for your contribution!** +- Backward compatible? (`Yes/No`) +- Config/env changes? (`Yes/No`) +- Migration needed? (`Yes/No`) +- If yes, upgrade steps: diff --git a/.github/workflows/pr-template-suggester.yml b/.github/workflows/pr-template-suggester.yml index 0798ca00..fe81fe5d 100644 --- a/.github/workflows/pr-template-suggester.yml +++ b/.github/workflows/pr-template-suggester.yml @@ -1,22 +1,18 @@ -name: PR Template Suggester +name: PR Labeler on: pull_request: - types: [opened, edited, synchronize] + types: [opened, synchronize, reopened] permissions: pull-requests: write - issues: write contents: read jobs: - suggest-template: + label-pr: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Analyze PR files and auto-apply template + - name: Analyze PR and apply labels uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -25,166 +21,72 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, + per_page: 100, }); let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0; + let additions = 0, deletions = 0; for (const file of files) { - const filename = file.filename.toLowerCase(); - if (filename.endsWith('.go')) goFiles++; - else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++; - else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++; - else if (filename.endsWith('.md')) mdFiles++; + const name = file.filename.toLowerCase(); + additions += file.additions || 0; + deletions += file.deletions || 0; + if (name.endsWith('.go')) goFiles++; + else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++; + else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++; + else if (name.endsWith('.md')) mdFiles++; else otherFiles++; } const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles; - if (totalFiles === 0) { console.log('No files changed'); return; } + if (totalFiles === 0) return; - let suggestedTemplate = null, templateEmoji = '', templateLabel = ''; + // --- Scope label --- + const labels = []; + if (goFiles / totalFiles > 0.5) labels.push('backend'); + else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend'); + else if (mdFiles / totalFiles > 0.7) labels.push('documentation'); + else labels.push('fullstack'); - if (goFiles / totalFiles > 0.5) { - suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend'; - } else if ((jsFiles + tsFiles) / totalFiles > 0.5) { - suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend'; - } else if (mdFiles / totalFiles > 0.7) { - suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation'; + // --- Size label (like OpenClaw) --- + const totalChanged = additions + deletions; + const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL']; + let sizeLabel = 'size: XL'; + if (totalChanged < 50) sizeLabel = 'size: XS'; + else if (totalChanged < 200) sizeLabel = 'size: S'; + else if (totalChanged < 500) sizeLabel = 'size: M'; + else if (totalChanged < 1000) sizeLabel = 'size: L'; + labels.push(sizeLabel); + + // Ensure size labels exist + for (const sl of sizeLabels) { + try { + await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' }); + } + } } - const { data: pr } = await github.rest.pulls.get({ + // Remove stale size labels + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + }); + for (const cl of currentLabels) { + if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name, + }).catch(() => {}); + } + } + + // Apply labels + await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: context.issue.number, + issue_number: context.issue.number, + labels: labels, }); - const prBody = pr.body || ''; - const usesBackendTemplate = prBody.includes('Pull Request - Backend'); - const usesFrontendTemplate = prBody.includes('Pull Request - Frontend'); - const usesDocsTemplate = prBody.includes('Pull Request - Documentation'); - const usesGeneralTemplate = prBody.includes('Pull Request - General'); - const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate; - - if (templateLabel) { - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: [templateLabel] - }); - console.log('Added label: ' + templateLabel); - } catch (error) { - console.log('Label might not exist, skipping...'); - } - } - - function isPRBodyEmpty(body) { - if (!body || body.trim().length < 100) return true; - const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/); - const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/); - if (hasEmptyDescription || hasEmptyChanges) return true; - const descMatch = body.match(/\*\*English:\*\*[||]\s*\*\*中文:\*\*\s*\n\s*(.+)/); - if (!descMatch || descMatch[1].trim().length < 10) return true; - return false; - } - - if (suggestedTemplate && usingDefaultTemplate) { - const shouldAutoApply = isPRBodyEmpty(prBody); - const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md'; - - if (shouldAutoApply) { - try { - const { data: templateFile } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: templatePath, - ref: context.payload.pull_request.head.ref - }); - - const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8'); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - body: templateContent - }); - - console.log('Auto-applied ' + suggestedTemplate + ' template'); - - let fileStats = []; - if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles); - if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles); - if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles); - if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles); - if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles); - const fileStatsText = fileStats.join('\n'); - - const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' + - '检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' + - 'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' + - '**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' + - '**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' + - '✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' + - '✨ You can now fill in the relevant information in the PR description!'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: notifyComment - }); - - } catch (error) { - console.log('Failed to fetch or apply template: ' + error.message); - const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md'; - const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' + - '您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' + - '**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' + - '**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: fallbackComment - }); - } - } else { - console.log('PR body has content, sending suggestion only'); - - let fileStats = []; - if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles); - if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles); - if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles); - if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles); - if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles); - const fileStatsText = fileStats.join('\n'); - - const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md'; - - const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' + - '您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' + - 'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' + - '**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' + - '**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' + - '**如何使用 | How to use**\n' + - '1. 编辑PR描述 | Edit PR description\n' + - '2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' + - '3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' + - ' `?template=' + suggestedTemplate + '.md`\n\n' + - '_这是一个自动建议,您可以继续使用当前模板。_\n\n' + - '_This is an automated suggestion. You may continue using the current template._'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); - } - } else if (suggestedTemplate && !usingDefaultTemplate) { - console.log('PR already uses a specific template'); - } else { - console.log('No specific template suggestion needed - mixed changes'); - } + console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`); diff --git a/.gitignore b/.gitignore index 7a2f6321..1f3eeb12 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ nofx_test # Go 相关 *.test *.out +.gocache/ # 操作系统 .DS_Store diff --git a/api/route_registry.go b/api/route_registry.go new file mode 100644 index 00000000..23bdef8d --- /dev/null +++ b/api/route_registry.go @@ -0,0 +1,66 @@ +package api + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" +) + +// RouteDoc holds documentation for a single API route. +type RouteDoc struct { + Method string + Path string + Description string + Schema string // optional: full parameter/body schema documentation +} + +// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes. +var routeRegistry []RouteDoc + +// route registers an HTTP route with a one-line description. +func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) { + s.routeWithSchema(g, method, path, description, "", h) +} + +// routeWithSchema registers an HTTP route with full parameter schema documentation. +// schema is injected verbatim into the API docs seen by the LLM. +func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) { + fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/") + routeRegistry = append(routeRegistry, RouteDoc{ + Method: method, + Path: fullPath, + Description: description, + Schema: schema, + }) + switch method { + case "GET": + g.GET(path, h) + case "POST": + g.POST(path, h) + case "PUT": + g.PUT(path, h) + case "DELETE": + g.DELETE(path, h) + } +} + +// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt. +// Routes with schema documentation include full parameter details. +func GetAPIDocs() string { + var sb strings.Builder + for _, r := range routeRegistry { + sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path)) + sb.WriteString(fmt.Sprintf(" %s\n", r.Description)) + if r.Schema != "" { + // Indent each schema line for readability + for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") { + sb.WriteString(" ") + sb.WriteString(line) + sb.WriteByte('\n') + } + } + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/api/server.go b/api/server.go index 09e9af85..74813fdd 100644 --- a/api/server.go +++ b/api/server.go @@ -40,14 +40,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 @@ -114,108 +115,276 @@ 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) - api.POST("/reset-password", s.handleResetPassword) + s.route(api, "POST", "/register", "Register new user", s.handleRegister) + s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) + s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword) // 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) + + // User account management + s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password", + `Body: {"new_password":""}`, + s.handleChangePassword) // 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.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status", + `Returns: [{"trader_id":"","trader_name":"","is_running":}] +NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this endpoint before querying data.`, + s.handleTraderList) + s.routeWithSchema(protected, "GET", "/traders/:id/config", "Get full trader configuration", + `:id = trader_id from GET /api/my-traders`, + s.handleGetTraderConfig) + s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader", + `Body: {"name":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":} +IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`, + s.handleCreateTrader) + s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration", + `:id = trader_id from GET /api/my-traders +Body: {"name":"","ai_model_id":"","exchange_id":"","strategy_id":"","scan_interval_minutes":,"is_cross_margin":} +Only include fields you want to change.`, + s.handleUpdateTrader) + s.routeWithSchema(protected, "DELETE", "/traders/:id", "Delete trader", + `:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`, + s.handleDeleteTrader) + s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading", + `:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`, + s.handleStartTrader) + s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading", + `:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`, + s.handleStopTrader) + s.routeWithSchema(protected, "PUT", "/traders/:id/prompt", "Override the trader's AI system prompt", + `Body: {"prompt":""}`, + s.handleUpdateTraderPrompt) + s.routeWithSchema(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", + `:id = trader_id from GET /api/my-traders. No request body needed. Refreshes initial_balance from the exchange.`, + s.handleSyncBalance) + s.routeWithSchema(protected, "POST", "/traders/:id/close-position", "Force-close an open position", + `:id = trader_id from GET /api/my-traders. +Body: {"symbol":""}`, + s.handleClosePosition) + s.routeWithSchema(protected, "PUT", "/traders/:id/competition", "Toggle competition leaderboard visibility", + `:id = trader_id from GET /api/my-traders. +Body: {"show_in_competition":}`, + s.handleToggleCompetition) + s.routeWithSchema(protected, "GET", "/traders/:id/grid-risk", "Get grid trading risk info", + `:id = trader_id from GET /api/my-traders.`, + s.handleGetGridRiskInfo) // AI model configuration - protected.GET("/models", s.handleGetModelConfigs) - protected.PUT("/models", s.handleUpdateModelConfigs) + s.routeWithSchema(protected, "GET", "/models", "List AI model configs", + `Returns: [{"id":"","name":"","provider":"","enabled":}] +CRITICAL: The "id" field (e.g. "abc123_deepseek") is what you must use for ai_model_id. The "provider" field ("deepseek") is NOT valid as an id.`, + s.handleGetModelConfigs) + s.routeWithSchema(protected, "PUT", "/models", "Configure an AI model provider", + `Body: {"models":{"":{"enabled":,"api_key":"","custom_api_url":"","custom_model_name":""}}} +model_id values: "openai","deepseek","qwen","kimi","grok","gemini","claude" +Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.deepseek.com, qwen→dashscope.aliyuncs.com/compatible-mode/v1, kimi→api.moonshot.ai/v1, grok→api.x.ai/v1, gemini→generativelanguage.googleapis.com/v1beta/openai, claude→api.anthropic.com/v1`, + s.handleUpdateModelConfigs) // Exchange configuration - protected.GET("/exchanges", s.handleGetExchangeConfigs) - protected.POST("/exchanges", s.handleCreateExchange) - protected.PUT("/exchanges", s.handleUpdateExchangeConfigs) - protected.DELETE("/exchanges/:id", s.handleDeleteExchange) + s.routeWithSchema(protected, "GET", "/exchanges", "List exchange accounts", + `Returns: [{"id":"","exchange_type":"","account_name":"","enabled":}] +CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`, + s.handleGetExchangeConfigs) + s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account", + `Body: {"exchange_type":"","account_name":"","enabled":true,"api_key":"","secret_key":"","passphrase":""} +exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX) +Required fields by exchange: + binance/bybit/bitget/indodax: api_key + secret_key + okx/gate/kucoin: api_key + secret_key + passphrase + hyperliquid: hyperliquid_wallet_addr + aster: aster_user + aster_signer + aster_private_key + lighter: lighter_wallet_addr + lighter_private_key + lighter_api_key_private_key + lighter_api_key_index`, + s.handleCreateExchange) + s.routeWithSchema(protected, "PUT", "/exchanges", "Update an existing exchange account configuration", + `Body: {"id":"","exchange_type":"","account_name":"","enabled":,"api_key":"","secret_key":"","passphrase":""} +Use this to enable/disable an exchange or update API credentials. The "id" field is required to identify which exchange to update.`, + s.handleUpdateExchangeConfigs) + s.routeWithSchema(protected, "DELETE", "/exchanges/:id", "Delete exchange account", + `:id = EXACT id from GET /api/exchanges. Permanently removes the exchange account and disconnects any traders using it.`, + s.handleDeleteExchange) + + // Telegram bot configuration + s.routeWithSchema(protected, "GET", "/telegram", "Get Telegram bot configuration", + `Returns: {"bot_token":"","model_id":"","chat_id":""}`, + s.handleGetTelegramConfig) + s.routeWithSchema(protected, "POST", "/telegram", "Set Telegram bot token and AI model", + `Body: {"bot_token":"","model_id":""} +Both fields are required. After saving, the user must send /start in Telegram to bind their account.`, + s.handleUpdateTelegramConfig) + s.routeWithSchema(protected, "POST", "/telegram/model", "Update Telegram bot AI model only", + `Body: {"model_id":""}`, + s.handleUpdateTelegramModel) + s.routeWithSchema(protected, "DELETE", "/telegram/binding", "Unbind Telegram account", + `No body needed. Clears the Telegram chat_id binding so the user can re-bind with /start.`, + 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.routeWithSchema(protected, "GET", "/strategies", "List user's strategies", + `Returns: [{"id":"","name":"","is_active":,"is_default":}] +CRITICAL: Always use the "id" field for strategy_id.`, + s.handleGetStrategies) + s.routeWithSchema(protected, "GET", "/strategies/active", "Get the currently active strategy", + `Returns the strategy marked is_active=true for this user, or the system default. Use this to find which strategy is currently in use.`, + s.handleGetActiveStrategy) + s.routeWithSchema(protected, "GET", "/strategies/default-config", "Get default strategy config with all fields and sensible values — use as reference for building configs", + `No parameters needed. Returns a complete StrategyConfig object with all fields populated with recommended defaults. Read this before building a custom config.`, + s.handleGetDefaultStrategyConfig) + s.route(protected, "POST", "/strategies/preview-prompt", "Preview the AI prompt that will be generated from a config", s.handlePreviewPrompt) + s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun) + s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy) + s.routeWithSchema(protected, "POST", "/strategies", "Create a new trading strategy", + `Body: {"name":"","description":"","lang":"zh|en","config":} +IMPORTANT: For most use cases just POST {"name":""} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes). + +StrategyConfig fields: + coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed" + coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static" + coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10) + coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection + indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h" + indicators.klines.primary_count: number of candles (20-100) + indicators.klines.enable_multi_timeframe: true for trend/swing analysis + indicators.klines.selected_timeframes: e.g. ["5m","15m","1h","4h"] + indicators.enable_raw_klines: ALWAYS true (raw OHLCV required) + indicators.enable_ema: true for trend-following (EMA crossover signals) + indicators.enable_macd: true for trend + momentum confirmation + indicators.enable_rsi: true for overbought/oversold, divergence detection + indicators.enable_boll: true for volatility, range trading, breakout strategies + indicators.enable_atr: true for volatility measurement and stop-loss sizing + indicators.enable_volume: ALWAYS true + indicators.enable_oi: ALWAYS true (open interest data) + indicators.enable_funding_rate: ALWAYS true + indicators.ema_periods: [20,50] default, [9,21] for faster signals + indicators.rsi_periods: [7,14] default + indicators.atr_periods: [14] default + indicators.boll_periods: [20] default + indicators.nofxos_api_key: ALWAYS "cm_568c67eae410d912c54c" + indicators.enable_quant_data: ALWAYS true + indicators.enable_quant_oi: ALWAYS true + indicators.enable_quant_netflow: ALWAYS true + indicators.enable_oi_ranking: ALWAYS true, oi_ranking_duration:"1h", oi_ranking_limit:10 + indicators.enable_netflow_ranking: ALWAYS true, netflow_ranking_duration:"1h", netflow_ranking_limit:10 + indicators.enable_price_ranking: ALWAYS true, price_ranking_duration:"1h,4h,24h", price_ranking_limit:10 + risk_control.max_positions: max simultaneous positions (1=single coin, 3=diversified, 5=wide) + risk_control.btc_eth_max_leverage: BTC/ETH leverage (conservative:3-5, moderate:5-10, aggressive:10-20) + risk_control.altcoin_max_leverage: altcoin leverage (usually lower than BTC leverage) + risk_control.btc_eth_max_position_value_ratio: max position size as multiple of equity (default 5) + risk_control.altcoin_max_position_value_ratio: default 1 + risk_control.max_margin_usage: 0.5-0.95 (default 0.9 = use up to 90% margin) + risk_control.min_position_size: minimum USDT per trade (default 12) + risk_control.min_risk_reward_ratio: minimum profit/loss ratio required (default 3 = 3:1) + risk_control.min_confidence: minimum AI confidence to open position (default 75, range 60-90) + prompt_sections.role_definition: describe the AI's trading persona and goal + prompt_sections.trading_frequency: guidelines on how often to trade + prompt_sections.entry_standards: conditions that must align before entering a position + prompt_sections.decision_process: step-by-step decision-making framework`, + s.handleCreateStrategy) + s.routeWithSchema(protected, "PUT", "/strategies/:id", "Update an existing strategy — WORKFLOW: 1) GET /api/strategies/:id first to read current config 2) Merge your changes into the full config 3) PUT with complete merged config 4) GET again to verify saved values", + `Body: {"name":"","description":"","config":} +IMPORTANT: config is merged with existing values server-side, but always send the complete section you are modifying. +After updating, always GET /api/strategies/:id to verify and show the user actual saved values.`, + s.handleUpdateStrategy) + s.routeWithSchema(protected, "DELETE", "/strategies/:id", "Delete strategy", + `:id = EXACT id from GET /api/strategies. Cannot delete a strategy that is currently assigned to a running trader.`, + s.handleDeleteStrategy) + s.routeWithSchema(protected, "POST", "/strategies/:id/activate", "Mark a strategy as the active strategy for this user", + `:id = EXACT id from GET /api/strategies. +No request body needed. Sets this strategy as is_active=true (and deactivates the previous active strategy). +After activating, create or update a trader with this strategy_id to apply it.`, + s.handleActivateStrategy) + s.routeWithSchema(protected, "POST", "/strategies/:id/duplicate", "Duplicate an existing strategy", + `:id = EXACT id from GET /api/strategies. Creates a copy with " (copy)" appended to the name.`, + 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) + // IMPORTANT: All ?trader_id= values must be the EXACT "trader_id" field from GET /api/my-traders + s.routeWithSchema(protected, "GET", "/status", "Trader running status", + `Query: ?trader_id= +Returns: {"is_running":,"trader_id":""}`, + s.handleStatus) + s.routeWithSchema(protected, "GET", "/account", "Account balance and equity", + `Query: ?trader_id= +Returns: {"balance":,"equity":,"unrealized_pnl":,"initial_balance":,"total_return_pct":}`, + s.handleAccount) + s.routeWithSchema(protected, "GET", "/positions", "Current open positions", + `Query: ?trader_id= +Returns: [{"symbol":"","side":"long|short","size":,"entry_price":,"mark_price":,"unrealized_pnl":,"leverage":}]`, + s.handlePositions) + s.routeWithSchema(protected, "GET", "/positions/history", "Closed position history", + `Query: ?trader_id=&limit=`, + s.handlePositionHistory) + s.routeWithSchema(protected, "GET", "/trades", "Trade records", + `Query: ?trader_id=&limit=`, + s.handleTrades) + s.routeWithSchema(protected, "GET", "/orders", "All order records", + `Query: ?trader_id=&limit=`, + s.handleOrders) + s.routeWithSchema(protected, "GET", "/orders/:id/fills", "Order fill details", + `:id = order id from GET /api/orders`, + s.handleOrderFills) + s.routeWithSchema(protected, "GET", "/open-orders", "Open orders currently on exchange", + `Query: ?trader_id=`, + s.handleOpenOrders) + s.routeWithSchema(protected, "GET", "/decisions", "AI trading decisions (decision records)", + `Query: ?trader_id=&limit= +Returns: [{"id":"","symbol":"","action":"open_long|open_short|close_long|close_short|hold","confidence":,"reasoning":"","created_at":""}]`, + s.handleDecisions) + s.routeWithSchema(protected, "GET", "/decisions/latest", "Latest AI decisions (most recent scan results)", + `Query: ?trader_id= +Returns the most recent AI decision for each symbol analyzed in the last scan cycle.`, + s.handleLatestDecisions) + s.routeWithSchema(protected, "GET", "/statistics", "Trading performance statistics", + `Query: ?trader_id= +Returns: {"total_trades":,"winning_trades":,"win_rate":,"total_pnl":,"sharpe_ratio":,"max_drawdown":}`, + s.handleStatistics) // Backtest routes backtest := protected.Group("/backtest") @@ -234,12 +403,11 @@ func (s *Server) handleHealth(c *gin.Context) { // handleGetSystemConfig Get system configuration (configuration that client needs to know) func (s *Server) handleGetSystemConfig(c *gin.Context) { - cfg := config.Get() - + userCount, _ := s.store.User().Count() c.JSON(http.StatusOK, gin.H{ - "registration_enabled": cfg.RegistrationEnabled, - "btc_eth_leverage": 10, // Default value - "altcoin_leverage": 5, // Default value + "initialized": userCount > 0, + "btc_eth_leverage": 10, + "altcoin_leverage": 5, }) } @@ -3087,11 +3255,18 @@ func (s *Server) handleLogout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) } -// handleRegister Handle user registration request +// handleRegister Handle user registration request. +// handleRegister allows registration only when no users exist yet (first-time setup). +// This is a single-user system; subsequent registrations are permanently closed. func (s *Server) handleRegister(c *gin.Context) { - // Check if registration is allowed - if !config.Get().RegistrationEnabled { - c.JSON(http.StatusForbidden, gin.H{"error": "Registration is disabled"}) + userCount, err := s.store.User().Count() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"}) + return + } + + if userCount > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"}) return } @@ -3106,26 +3281,12 @@ func (s *Server) handleRegister(c *gin.Context) { } // Check if email already exists - _, err := s.store.User().GetByEmail(req.Email) + _, err = s.store.User().GetByEmail(req.Email) if err == nil { c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) return } - // Check max users limit (only for new users) - maxUsers := config.Get().MaxUsers - if maxUsers > 0 { - userCount, err := s.store.User().Count() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"}) - return - } - if userCount >= maxUsers { - c.JSON(http.StatusForbidden, gin.H{"error": "Not on whitelist"}) - return - } - } - // Generate password hash passwordHash, err := auth.HashPassword(req.Password) if err != nil { @@ -3208,6 +3369,28 @@ func (s *Server) handleLogin(c *gin.Context) { }) } +// handleChangePassword changes the password for the currently authenticated user. +func (s *Server) handleChangePassword(c *gin.Context) { + userID := c.GetString("user_id") + var req struct { + NewPassword string `json:"new_password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&req); err != nil { + SafeBadRequest(c, "new_password is required (min 8 chars)") + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + SafeInternalError(c, "Password processing failed", err) + return + } + if err := s.store.User().UpdatePassword(userID, hash); err != nil { + SafeInternalError(c, "Failed to update password", err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Password updated"}) +} + // handleResetPassword Reset password via email and new password func (s *Server) handleResetPassword(c *gin.Context) { var req struct { @@ -3267,6 +3450,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) { {"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"}, {"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"}, {"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"}, + {"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"}, } c.JSON(http.StatusOK, supportedModels) @@ -3626,3 +3810,106 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) { c.JSON(http.StatusOK, result) } + +// SetTelegramReloadCh sets the channel used to signal the Telegram bot to reload +func (s *Server) SetTelegramReloadCh(ch chan<- struct{}) { + s.telegramReloadCh = ch +} + +// handleGetTelegramConfig returns current Telegram bot configuration and binding status +func (s *Server) handleGetTelegramConfig(c *gin.Context) { + cfg, err := s.store.TelegramConfig().Get() + if err != nil { + // Not configured yet - return empty state + c.JSON(http.StatusOK, gin.H{ + "configured": false, + "is_bound": false, + "token_masked": "", + "username": "", + }) + return + } + + // Mask bot token for security (show only last 6 chars) + tokenMasked := "" + if cfg.BotToken != "" { + if len(cfg.BotToken) > 6 { + tokenMasked = "***" + cfg.BotToken[len(cfg.BotToken)-6:] + } else { + tokenMasked = "***" + } + } + + c.JSON(http.StatusOK, gin.H{ + "configured": cfg.BotToken != "", + "is_bound": cfg.ChatID != 0, + "username": cfg.Username, + "bound_at": cfg.BoundAt, + "token_masked": tokenMasked, + "model_id": cfg.ModelID, + }) +} + +// handleUpdateTelegramConfig saves bot token (+ optional model ID) and triggers bot hot-reload +func (s *Server) handleUpdateTelegramConfig(c *gin.Context) { + var req struct { + BotToken string `json:"bot_token"` + ModelID string `json:"model_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.BotToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"}) + return + } + + if err := s.store.TelegramConfig().Save(req.BotToken, req.ModelID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"}) + return + } + + // Signal bot hot-reload if channel is available + if s.telegramReloadCh != nil { + select { + case s.telegramReloadCh <- struct{}{}: + default: // non-blocking + } + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "Bot token saved. Bot will reload automatically."}) +} + +// handleUnbindTelegram removes Telegram user binding +func (s *Server) handleUnbindTelegram(c *gin.Context) { + if err := s.store.TelegramConfig().Unbind(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unbind"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "Telegram binding removed"}) +} + +// handleUpdateTelegramModel updates only the AI model used for Telegram replies (no token re-entry needed) +func (s *Server) handleUpdateTelegramModel(c *gin.Context) { + var req struct { + ModelID string `json:"model_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + cfg, err := s.store.TelegramConfig().Get() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no Telegram config found, save a bot token first"}) + return + } + + if err := s.store.TelegramConfig().Save(cfg.BotToken, req.ModelID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model config"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "model_id": req.ModelID}) +} diff --git a/api/strategy.go b/api/strategy.go index ec11f7a9..c6599d1d 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -136,7 +136,8 @@ func (s *Server) handleGetStrategy(c *gin.Context) { }) } -// handleCreateStrategy Create strategy +// handleCreateStrategy Create strategy. +// If "config" is omitted from the request body, the system default config is used automatically. func (s *Server) handleCreateStrategy(c *gin.Context) { userID := c.GetString("user_id") if userID == "" { @@ -145,9 +146,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { } var req struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Config store.StrategyConfig `json:"config" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Lang string `json:"lang"` // "zh" or "en", used when config is omitted + Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted } if err := c.ShouldBindJSON(&req); err != nil { @@ -155,6 +157,16 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { return } + // Use default config when none provided + if req.Config == nil { + lang := req.Lang + if lang == "" { + lang = "zh" + } + defaultCfg := store.GetDefaultStrategyConfig(lang) + req.Config = &defaultCfg + } + // Serialize configuration configJSON, err := json.Marshal(req.Config) if err != nil { @@ -178,7 +190,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { } // Validate configuration and collect warnings - warnings := validateStrategyConfig(&req.Config) + warnings := validateStrategyConfig(req.Config) response := gin.H{ "id": strategy.ID, @@ -191,7 +203,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { c.JSON(http.StatusOK, response) } -// handleUpdateStrategy Update strategy +// handleUpdateStrategy Update strategy. +// The incoming config is merged with the existing one: top-level sections present in the +// request overwrite the corresponding existing sections; absent sections are preserved. +// This prevents partial updates from zeroing out unmentioned fields. func (s *Server) handleUpdateStrategy(c *gin.Context) { userID := c.GetString("user_id") strategyID := c.Param("id") @@ -213,11 +228,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { } var req struct { - Name string `json:"name"` - Description string `json:"description"` - Config store.StrategyConfig `json:"config"` - IsPublic bool `json:"is_public"` - ConfigVisible bool `json:"config_visible"` + Name string `json:"name"` + Description string `json:"description"` + Config json.RawMessage `json:"config"` // raw JSON so we can merge + IsPublic bool `json:"is_public"` + ConfigVisible bool `json:"config_visible"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -225,8 +240,33 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { return } - // Serialize configuration - configJSON, err := json.Marshal(req.Config) + // Start with the existing config as base — preserves all unmentioned fields. + var mergedConfig store.StrategyConfig + if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil { + // If existing config is corrupt, start from zero + mergedConfig = store.StrategyConfig{} + } + + // Apply incoming config on top: top-level sections present in the request overwrite + // their corresponding existing section; absent sections remain unchanged. + if len(req.Config) > 0 && string(req.Config) != "null" { + if err := json.Unmarshal(req.Config, &mergedConfig); err != nil { + SafeBadRequest(c, "Invalid config JSON") + return + } + } + + // Preserve existing name/description when not supplied + name := req.Name + if name == "" { + name = existing.Name + } + description := req.Description + if description == "" { + description = existing.Description + } + + configJSON, err := json.Marshal(mergedConfig) if err != nil { SafeInternalError(c, "Serialize configuration", err) return @@ -235,8 +275,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { strategy := &store.Strategy{ ID: strategyID, UserID: userID, - Name: req.Name, - Description: req.Description, + Name: name, + Description: description, Config: string(configJSON), IsPublic: req.IsPublic, ConfigVisible: req.ConfigVisible, @@ -247,8 +287,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { return } - // Validate configuration and collect warnings - warnings := validateStrategyConfig(&req.Config) + // Validate merged configuration and collect warnings + warnings := validateStrategyConfig(&mergedConfig) response := gin.H{"message": "Strategy updated successfully"} if len(warnings) > 0 { @@ -628,6 +668,15 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) case "minimax": aiClient = mcp.NewMiniMaxClient() aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName) + case "blockrun-base": + aiClient = mcp.NewBlockRunBaseClient() + aiClient.SetAPIKey(apiKey, "", model.CustomModelName) + case "blockrun-sol": + aiClient = mcp.NewBlockRunSolClient() + aiClient.SetAPIKey(apiKey, "", model.CustomModelName) + case "claw402": + aiClient = mcp.NewClaw402Client() + aiClient.SetAPIKey(apiKey, "", model.CustomModelName) default: // Use generic client aiClient = mcp.NewClient() diff --git a/backtest/ai_client.go b/backtest/ai_client.go index 74c34761..203e33d1 100644 --- a/backtest/ai_client.go +++ b/backtest/ai_client.go @@ -78,6 +78,27 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er mmC := mcp.NewMiniMaxClientWithOptions() mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model) return mmC, nil + case "blockrun-base": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("blockrun-base provider requires wallet private key") + } + brBase := mcp.NewBlockRunBaseClient() + brBase.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model) + return brBase, nil + case "blockrun-sol": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("blockrun-sol provider requires wallet keypair") + } + brSol := mcp.NewBlockRunSolClient() + brSol.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model) + return brSol, nil + case "claw402": + if cfg.AICfg.APIKey == "" { + return nil, fmt.Errorf("claw402 provider requires wallet private key") + } + claw := mcp.NewClaw402Client() + claw.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model) + return claw, nil case "custom": if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" { return nil, fmt.Errorf("custom provider requires base_url, api key and model") diff --git a/config/config.go b/config/config.go index 1a4a0d96..56ad3601 100644 --- a/config/config.go +++ b/config/config.go @@ -15,10 +15,8 @@ var global *Config // Only contains truly global config, trading related config is at trader/strategy level type Config struct { // Service configuration - APIServerPort int - JWTSecret string - RegistrationEnabled bool - MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10) + APIServerPort int + JWTSecret string // Database configuration DBType string // sqlite or postgres @@ -44,14 +42,13 @@ type Config struct { AlpacaAPIKey string // Alpaca API key for US stocks AlpacaSecretKey string // Alpaca secret key TwelveDataKey string // TwelveData API key for forex & metals + } // Init initializes global configuration (from .env) func Init() { cfg := &Config{ APIServerPort: 8080, - RegistrationEnabled: true, - MaxUsers: 10, // Default: 10 users allowed ExperienceImprovement: true, // Default: enabled to help improve the product // Database defaults DBType: "sqlite", @@ -71,16 +68,6 @@ func Init() { cfg.JWTSecret = "default-jwt-secret-change-in-production" } - if v := os.Getenv("REGISTRATION_ENABLED"); v != "" { - cfg.RegistrationEnabled = strings.ToLower(v) == "true" - } - - if v := os.Getenv("MAX_USERS"); v != "" { - if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 { - cfg.MaxUsers = maxUsers - } - } - if v := os.Getenv("API_SERVER_PORT"); v != "" { if port, err := strconv.Atoi(v); err == nil && port > 0 { cfg.APIServerPort = port diff --git a/debate/engine.go b/debate/engine.go index 9d9dffb1..37f8376c 100644 --- a/debate/engine.go +++ b/debate/engine.go @@ -99,6 +99,12 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant client = mcp.NewKimiClient() case "minimax": client = mcp.NewMiniMaxClient() + case "blockrun-base": + client = mcp.NewBlockRunBaseClient() + case "blockrun-sol": + client = mcp.NewBlockRunSolClient() + case "claw402": + client = mcp.NewClaw402Client() default: client = mcp.New() } diff --git a/docs/plans/2026-03-06-telegram-agent-redesign.md b/docs/plans/2026-03-06-telegram-agent-redesign.md new file mode 100644 index 00000000..9764d985 --- /dev/null +++ b/docs/plans/2026-03-06-telegram-agent-redesign.md @@ -0,0 +1,1039 @@ +# Telegram Bot Agent Redesign (OpenClaw-Inspired) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Replace the NLU intent-classification architecture with a true AI Agent that handles any user request — including scenarios never explicitly programmed. All code, comments, prompts, and bot responses in English. + +**Architecture:** One generic tool (`api_call`) + dynamically generated API docs + unbounded LLM loop. The LLM reads auto-generated API docs and decides which endpoints to call. New features added to the web UI automatically become available via bot — zero code changes required. + +**Tech Stack:** Go, `mcp.CallWithRequest` + `RequestBuilder`, `tgbotapi`, `auth.GenerateJWT` + +--- + +## Core Design + +OpenClaw gives LLM a `bash` tool — one generic primitive, unlimited capability. +We give LLM an `api_call(method, path, body)` tool — one generic primitive for 74+ REST endpoints. + +**Auto-discovery:** Routes are registered via `s.route(group, method, path, description, handler)`. +`api.GetAPIDocs()` returns live documentation at startup — add a route and it's automatically in the bot's context. + +``` +User: "show positions and stop the trader if loss > 5%" + +Iteration 1: api_call GET /api/positions?trader_id=... +Iteration 2: api_call GET /api/account?trader_id=... +Iteration 3: [sees -8% loss] api_call POST /api/traders/xxx/stop +Reply: "Detected -8% loss. Trader stopped." +``` + +No special code for this scenario. LLM figured it out from the API docs. + +--- + +## What changes + +| File | Action | +|------|--------| +| `api/route_registry.go` | **CREATE** — route registration + doc generation | +| `api/server.go` | Migrate all routes from `group.METHOD(path, handler)` to `s.route(group, method, path, desc, handler)` | +| `telegram/intent/parser.go` | **DELETE** | +| `telegram/handler/handler.go` | **DELETE** | +| `telegram/handler/handler_test.go` | **DELETE** | +| `telegram/session/session.go` | Simplify (remove Intent, Params) | +| `telegram/bot.go` | Use `agent.Manager`, pass `api.GetAPIDocs()` | +| `telegram/agent/prompt.go` | **CREATE** — system prompt template (API docs injected at runtime) | +| `telegram/agent/apicall.go` | **CREATE** — the single generic tool | +| `telegram/agent/agent.go` | **CREATE** — agent loop | +| `telegram/agent/manager.go` | **CREATE** — per-chat serialization | +| `telegram/agent/agent_test.go` | **CREATE** — tests | + +`telegram/service/nofx.go` and `telegram/session/memory.go` are **unchanged**. + +--- + +## Task 1: Create `api/route_registry.go` + +**Files:** +- Create: `api/route_registry.go` + +This is the single source of truth for API documentation. Routes registered here are automatically available to the bot. + +```go +package api + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" +) + +// RouteDoc holds documentation for a single API route. +type RouteDoc struct { + Method string + Path string + Description string +} + +// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes. +var routeRegistry []RouteDoc + +// route registers an HTTP route on the given group and records its documentation. +// This is the single registration point — add a route here and it is automatically +// included in GetAPIDocs(), making it available to the Telegram bot agent. +func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) { + // Derive the full path: group prefix + local path + fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/") + routeRegistry = append(routeRegistry, RouteDoc{ + Method: method, + Path: fullPath, + Description: description, + }) + switch method { + case "GET": + g.GET(path, h) + case "POST": + g.POST(path, h) + case "PUT": + g.PUT(path, h) + case "DELETE": + g.DELETE(path, h) + } +} + +// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt. +// Called once at bot startup — reflects the live set of registered routes. +func GetAPIDocs() string { + var sb strings.Builder + for _, r := range routeRegistry { + sb.WriteString(fmt.Sprintf("%-8s %-50s %s\n", r.Method, r.Path, r.Description)) + } + return sb.String() +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./api/... +``` + +Expected: clean build. + +**Step 3: Commit** + +```bash +git add api/route_registry.go +git commit -m "feat(api): add route registry for auto-generated API documentation" +``` + +--- + +## Task 2: Migrate routes in `api/server.go` + +**Files:** +- Modify: `api/server.go` (the `setupRoutes` / route registration block, lines ~109–230) + +Replace every direct `group.METHOD(path, handler)` call with `s.route(group, method, path, description, handler)`. + +**Step 1: Read the current route registration block** + +```bash +sed -n '109,230p' api/server.go +``` + +**Step 2: Replace all route registrations** + +The full replacement (covers all routes found in lines 117–223): + +```go +// Public routes +s.route(api, "GET", "/supported-models", "List supported AI model providers", s.handleGetSupportedModels) +s.route(api, "GET", "/supported-exchanges", "List supported exchange types", s.handleGetSupportedExchanges) +s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig) +s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList) +s.route(api, "GET", "/competition", "Public competition data", s.handlePublicCompetition) +s.route(api, "GET", "/top-traders", "Top traders leaderboard", s.handleTopTraders) +s.route(api, "GET", "/equity-history", "Equity history for a trader", s.handleEquityHistory) +s.route(api, "POST", "/equity-history-batch", "Batch equity history for multiple traders", s.handleEquityHistoryBatch) +s.route(api, "GET", "/traders/:id/public-config", "Public trader configuration", s.handleGetPublicTraderConfig) +s.route(api, "GET", "/klines", "Candlestick data (?symbol=&interval=&limit=)", s.handleKlines) +s.route(api, "GET", "/symbols", "Available trading symbols", s.handleSymbols) +s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies) +s.route(api, "POST", "/register", "Register new user", s.handleRegister) +s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin) + +// Protected routes (JWT required) +s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout) +s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP) + +// Trader management +s.route(protected, "GET", "/my-traders", "List user's traders", s.handleTraderList) +s.route(protected, "GET", "/traders/:id/config", "Get full trader configuration", s.handleGetTraderConfig) +s.route(protected, "POST", "/traders", "Create trader (body: name, strategy_id, exchange_id, model_id)", s.handleCreateTrader) +s.route(protected, "PUT", "/traders/:id", "Update trader configuration", s.handleUpdateTrader) +s.route(protected, "DELETE", "/traders/:id", "Delete trader", s.handleDeleteTrader) +s.route(protected, "POST", "/traders/:id/start", "Start trader", s.handleStartTrader) +s.route(protected, "POST", "/traders/:id/stop", "Stop trader", s.handleStopTrader) +s.route(protected, "PUT", "/traders/:id/prompt", "Update trader prompt (body: prompt)", s.handleUpdateTraderPrompt) +s.route(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", s.handleSyncBalance) +s.route(protected, "POST", "/traders/:id/close-position", "Close position (body: symbol)", s.handleClosePosition) +s.route(protected, "PUT", "/traders/:id/competition", "Toggle competition visibility", s.handleToggleCompetition) +s.route(protected, "GET", "/traders/:id/grid-risk", "Get grid risk info", s.handleGetGridRiskInfo) + +// AI model configuration +s.route(protected, "GET", "/models", "List AI model configurations", s.handleGetModelConfigs) +s.route(protected, "PUT", "/models", "Update AI model configurations", s.handleUpdateModelConfigs) + +// Exchange configuration +s.route(protected, "GET", "/exchanges", "List exchange configurations", s.handleGetExchangeConfigs) +s.route(protected, "POST", "/exchanges", "Create exchange (body: exchange_type, api_key, secret_key, account_name)", s.handleCreateExchange) +s.route(protected, "PUT", "/exchanges", "Update exchange configurations", s.handleUpdateExchangeConfigs) +s.route(protected, "DELETE", "/exchanges/:id", "Delete exchange", s.handleDeleteExchange) + +// Telegram configuration +s.route(protected, "GET", "/telegram", "Get Telegram bot configuration", s.handleGetTelegramConfig) +s.route(protected, "POST", "/telegram", "Update Telegram bot token/model", s.handleUpdateTelegramConfig) +s.route(protected, "POST", "/telegram/model", "Update Telegram bot AI model only", s.handleUpdateTelegramModel) +s.route(protected, "DELETE", "/telegram/binding", "Unbind Telegram account", s.handleUnbindTelegram) + +// Strategy management +s.route(protected, "GET", "/strategies", "List user's strategies", s.handleGetStrategies) +s.route(protected, "GET", "/strategies/active", "Get active strategy", s.handleGetActiveStrategy) +s.route(protected, "GET", "/strategies/default-config", "Get default strategy config template", s.handleGetDefaultStrategyConfig) +s.route(protected, "POST", "/strategies/preview-prompt", "Preview generated strategy prompt", s.handlePreviewPrompt) +s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun) +s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy) +s.route(protected, "POST", "/strategies", "Create strategy (body: name, config)", s.handleCreateStrategy) +s.route(protected, "PUT", "/strategies/:id", "Update strategy", s.handleUpdateStrategy) +s.route(protected, "DELETE", "/strategies/:id", "Delete strategy", s.handleDeleteStrategy) +s.route(protected, "POST", "/strategies/:id/activate", "Activate strategy", s.handleActivateStrategy) +s.route(protected, "POST", "/strategies/:id/duplicate", "Duplicate strategy", s.handleDuplicateStrategy) + +// Debate arena +s.route(protected, "GET", "/debates", "List debates", s.debateHandler.HandleListDebates) +s.route(protected, "GET", "/debates/personalities", "Available AI personalities", s.debateHandler.HandleGetPersonalities) +s.route(protected, "GET", "/debates/:id", "Get debate details", s.debateHandler.HandleGetDebate) +s.route(protected, "POST", "/debates", "Create debate", s.debateHandler.HandleCreateDebate) +s.route(protected, "POST", "/debates/:id/start", "Start debate", s.debateHandler.HandleStartDebate) +s.route(protected, "POST", "/debates/:id/cancel", "Cancel debate", s.debateHandler.HandleCancelDebate) +s.route(protected, "POST", "/debates/:id/execute", "Execute debate consensus decision", s.debateHandler.HandleExecuteDebate) +s.route(protected, "DELETE", "/debates/:id", "Delete debate", s.debateHandler.HandleDeleteDebate) +s.route(protected, "GET", "/debates/:id/messages", "Get debate messages", s.debateHandler.HandleGetMessages) +s.route(protected, "GET", "/debates/:id/votes", "Get debate votes", s.debateHandler.HandleGetVotes) +s.route(protected, "GET", "/debates/:id/stream", "SSE stream for live debate", s.debateHandler.HandleDebateStream) + +// Account and trading data (use ?trader_id=xxx query param) +s.route(protected, "GET", "/status", "Trader running status (?trader_id=)", s.handleStatus) +s.route(protected, "GET", "/account", "Account balance and equity (?trader_id=)", s.handleAccount) +s.route(protected, "GET", "/positions", "Current open positions (?trader_id=)", s.handlePositions) +s.route(protected, "GET", "/positions/history", "Position history (?trader_id=)", s.handlePositionHistory) +s.route(protected, "GET", "/trades", "Trade records (?trader_id=)", s.handleTrades) +s.route(protected, "GET", "/orders", "All orders (?trader_id=)", s.handleOrders) +s.route(protected, "GET", "/orders/:id/fills", "Order fill details", s.handleOrderFills) +s.route(protected, "GET", "/open-orders", "Open orders from exchange (?trader_id=)", s.handleOpenOrders) +s.route(protected, "GET", "/decisions", "AI trading decisions (?trader_id=)", s.handleDecisions) +s.route(protected, "GET", "/decisions/latest", "Latest AI decisions (?trader_id=)", s.handleLatestDecisions) +s.route(protected, "GET", "/statistics", "Trading statistics (?trader_id=)", s.handleStatistics) +``` + +Note: keep the existing special-case handlers that don't use `s.route` unchanged: +- `api.Any("/health", ...)` — health check, no need to document +- `api.GET("/crypto/...")` — crypto/encryption routes, bot doesn't need these +- `backtest.*` routes (registered separately) — add descriptions to the backtest group similarly + +**Step 3: Build** + +```bash +go build ./api/... +``` + +Expected: clean build. Fix any compilation errors (method signature mismatches). + +**Step 4: Verify docs are generated** + +```bash +go test ./api/... -run TestGetAPIDocs -v +``` + +(Write a quick inline test or just print in main to verify) + +**Step 5: Commit** + +```bash +git add api/route_registry.go api/server.go +git commit -m "feat(api): migrate routes to self-documenting s.route() registration" +``` + +--- + +## Task 3: Create `telegram/agent/prompt.go` + +**Files:** +- Create: `telegram/agent/prompt.go` + +The system prompt template. API docs are injected at runtime via `BuildAgentPrompt(apiDocs)`. + +```go +package agent + +import "fmt" + +// BuildAgentPrompt constructs the full system prompt with live API documentation injected. +// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes. +func BuildAgentPrompt(apiDocs string) string { + return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant. +You can have natural conversations with the user and call the API to operate the system. + +## Tool + +You have one tool: api_call + +Call format (append at end of reply): +{"method":"GET","path":"/api/xxx","body":{}} + +- method: "GET" | "POST" | "PUT" | "DELETE" +- path: API path from the documentation below +- body: request body as JSON object (use {} for GET requests) +- query parameters go in the path, e.g. /api/positions?trader_id=xxx + +## NOFX API Documentation + +All requests are pre-authenticated. Focus on paths and parameters. + +%s + +## Rules +1. When you need to perform a system operation, append ... at the end of your reply +2. Only call one API per response; after receiving the result, decide whether to call another or give a final reply +3. For conversations, questions, or analysis that don't require system operations, reply directly without calling the API +4. If required parameters are unclear, ask the user — do not guess critical values like trader_id +5. Always reply in English`, apiDocs) +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +go build ./telegram/agent/... +``` + +**Step 3: Commit** + +```bash +git add telegram/agent/prompt.go +git commit -m "feat(telegram/agent): add dynamic system prompt builder" +``` + +--- + +## Task 4: Create `telegram/agent/apicall.go` + +**Files:** +- Create: `telegram/agent/apicall.go` + +```go +package agent + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "nofx/logger" + "strings" + "time" +) + +// apiCallTool executes HTTP requests against the NOFX API server. +// This is the only tool available to the agent. +type apiCallTool struct { + baseURL string + token string + client *http.Client +} + +// apiRequest is the parsed structure from the LLM's tag. +type apiRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Body map[string]any `json:"body"` +} + +func newAPICallTool(port int, token string) *apiCallTool { + return &apiCallTool{ + baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + token: token, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// execute calls the API and returns the response as a string for LLM consumption. +func (t *apiCallTool) execute(req *apiRequest) string { + if req.Method == "" || req.Path == "" { + return "error: method and path are required" + } + if !strings.HasPrefix(req.Path, "/") { + req.Path = "/" + req.Path + } + + var bodyReader io.Reader + if req.Method != "GET" && len(req.Body) > 0 { + b, err := json.Marshal(req.Body) + if err != nil { + return fmt.Sprintf("error marshaling body: %v", err) + } + bodyReader = bytes.NewReader(b) + } + + httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader) + if err != nil { + return fmt.Sprintf("error creating request: %v", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+t.token) + + resp, err := t.client.Do(httpReq) + if err != nil { + return fmt.Sprintf("API call failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Sprintf("error reading response: %v", err) + } + + logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode) + + if resp.StatusCode >= 400 { + return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body)) + } + + // Pretty-print JSON for better LLM readability + var v any + if json.Unmarshal(body, &v) == nil { + if pretty, err := json.MarshalIndent(v, "", " "); err == nil { + return string(pretty) + } + } + return string(body) +} + +// parseAPICall extracts ... from LLM response. +// Returns (nil, original) if not found or malformed JSON. +func parseAPICall(resp string) (*apiRequest, string) { + const openTag = "" + const closeTag = "" + + start := strings.Index(resp, openTag) + end := strings.Index(resp, closeTag) + if start < 0 || end < 0 || end <= start { + return nil, resp + } + + jsonStr := strings.TrimSpace(resp[start+len(openTag) : end]) + var req apiRequest + if err := json.Unmarshal([]byte(jsonStr), &req); err != nil { + logger.Warnf("Agent: failed to parse api_call JSON %q: %v", jsonStr, err) + return nil, resp + } + + return &req, strings.TrimSpace(resp[:start]) +} +``` + +**Step 1: Create the file** + +**Step 2: Commit** + +```bash +git add telegram/agent/apicall.go +git commit -m "feat(telegram/agent): add generic api_call tool" +``` + +--- + +## Task 5: Create `telegram/agent/agent.go` + +**Files:** +- Create: `telegram/agent/agent.go` + +```go +package agent + +import ( + "fmt" + "nofx/auth" + "nofx/logger" + "nofx/mcp" + "nofx/telegram/session" + "strings" +) + +const maxIterations = 10 + +// Agent is a stateful AI agent for one Telegram chat. +// It has a single tool (api_call) and an unbounded decision loop. +type Agent struct { + apiTool *apiCallTool + getLLM func() mcp.AIClient + memory *session.Memory + systemPrompt string +} + +// New creates an Agent for one chat session. +func New(apiPort int, botToken string, getLLM func() mcp.AIClient, systemPrompt string) *Agent { + return &Agent{ + apiTool: newAPICallTool(apiPort, botToken), + getLLM: getLLM, + memory: session.NewMemory(getLLM()), + systemPrompt: systemPrompt, + } +} + +// GenerateBotToken creates a long-lived JWT for the bot's internal API calls. +// Call once at bot startup before creating any Agent or Manager. +func GenerateBotToken() (string, error) { + return auth.GenerateJWT("default", "bot@internal") +} + +// Run processes one user message through the agent loop. +// Loop: LLM decides -> if : execute, append result, loop -> if no tag: return reply. +func (a *Agent) Run(userMessage string) string { + llm := a.getLLM() + if llm == nil { + return "AI assistant unavailable. Please configure an AI model in the Web UI." + } + + // Build turn messages: history context prefix + current user message + histCtx := a.memory.BuildContext() + firstMsg := userMessage + if histCtx != "" { + firstMsg = histCtx + "\n---\nUser: " + userMessage + } + turnMsgs := []mcp.Message{mcp.NewUserMessage(firstMsg)} + + var lastResp string + + for i := 0; i < maxIterations; i++ { + req, err := mcp.NewRequestBuilder(). + WithSystemPrompt(a.systemPrompt). + AddConversationHistory(turnMsgs). + Build() + if err != nil { + logger.Errorf("Agent: failed to build request: %v", err) + break + } + + resp, err := llm.CallWithRequest(req) + if err != nil { + logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err) + return "AI assistant temporarily unavailable. Please try again." + } + lastResp = resp + + apiReq, textBefore := parseAPICall(resp) + if apiReq == nil { + // No api_call tag — LLM gave a final answer + reply := strings.TrimSpace(resp) + a.memory.Add("user", userMessage) + a.memory.Add("assistant", reply) + return reply + } + + logger.Infof("Agent: iter=%d %s %s", i+1, apiReq.Method, apiReq.Path) + result := a.apiTool.execute(apiReq) + + if textBefore != "" { + turnMsgs = append(turnMsgs, mcp.NewAssistantMessage(textBefore)) + } + turnMsgs = append(turnMsgs, mcp.NewUserMessage( + fmt.Sprintf("[API result: %s %s]\n%s", apiReq.Method, apiReq.Path, result), + )) + } + + // Safety: max iterations reached — ask LLM for a final summary + logger.Warnf("Agent: max iterations (%d) reached", maxIterations) + turnMsgs = append(turnMsgs, mcp.NewUserMessage("Please summarize the results and give the user a final reply.")) + if finalReq, err := mcp.NewRequestBuilder(). + WithSystemPrompt(a.systemPrompt). + AddConversationHistory(turnMsgs). + Build(); err == nil { + if finalResp, err := llm.CallWithRequest(finalReq); err == nil { + lastResp = finalResp + } + } + + reply := strings.TrimSpace(lastResp) + a.memory.Add("user", userMessage) + a.memory.Add("assistant", reply) + return reply +} + +// ResetMemory clears conversation history (called on /start). +func (a *Agent) ResetMemory() { + a.memory.ResetFull() +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +go build ./telegram/agent/... +``` + +**Step 3: Commit** + +```bash +git add telegram/agent/agent.go +git commit -m "feat(telegram/agent): add OpenClaw-style agent loop" +``` + +--- + +## Task 6: Create `telegram/agent/manager.go` + +**Files:** +- Create: `telegram/agent/manager.go` + +```go +package agent + +import ( + "nofx/mcp" + "sync" +) + +// Manager holds one Agent per Telegram chat ID. +// Messages for the same chat are serialized (OpenClaw Lane Queue pattern). +type Manager struct { + mu sync.Mutex + agents map[int64]*Agent + lanes map[int64]chan struct{} + apiPort int + botToken string + getLLM func() mcp.AIClient + systemPrompt string +} + +// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs. +func NewManager(apiPort int, botToken string, getLLM func() mcp.AIClient, apiDocs string) *Manager { + return &Manager{ + agents: make(map[int64]*Agent), + lanes: make(map[int64]chan struct{}), + apiPort: apiPort, + botToken: botToken, + getLLM: getLLM, + systemPrompt: BuildAgentPrompt(apiDocs), + } +} + +// Run processes a message for the given chat ID. +// If the same chat is already processing a message, this call blocks until it completes. +func (m *Manager) Run(chatID int64, userMessage string) string { + a, lane := m.getOrCreate(chatID) + lane <- struct{}{} + defer func() { <-lane }() + return a.Run(userMessage) +} + +// Reset clears memory for the given chat (called on /start). +func (m *Manager) Reset(chatID int64) { + m.mu.Lock() + a, ok := m.agents[chatID] + m.mu.Unlock() + if ok { + a.ResetMemory() + } +} + +func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.agents[chatID] + if !ok { + a = New(m.apiPort, m.botToken, m.getLLM, m.systemPrompt) + m.agents[chatID] = a + } + lane, ok := m.lanes[chatID] + if !ok { + lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat + m.lanes[chatID] = lane + } + return a, lane +} +``` + +**Step 1: Create the file** + +**Step 2: Build** + +```bash +go build ./telegram/agent/... +``` + +**Step 3: Commit** + +```bash +git add telegram/agent/manager.go +git commit -m "feat(telegram/agent): add per-chat agent manager with lane serialization" +``` + +--- + +## Task 7: Write tests + +**Files:** +- Create: `telegram/agent/agent_test.go` + +```go +package agent + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "nofx/mcp" +) + +type mockLLM struct { + responses []string + calls int + lastMsgs []mcp.Message +} + +func (m *mockLLM) SetAPIKey(_, _, _ string) {} +func (m *mockLLM) SetTimeout(_ time.Duration) {} +func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return m.next() } +func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) { + m.lastMsgs = req.Messages + return m.next() +} +func (m *mockLLM) next() (string, error) { + if m.calls < len(m.responses) { + r := m.responses[m.calls] + m.calls++ + return r, nil + } + return "OK", nil +} + +func mockGetLLM(llm *mockLLM) func() mcp.AIClient { + return func() mcp.AIClient { return llm } +} + +const testPrompt = "You are a test assistant." + +// TestAgentDirectReply: LLM replies without api_call — one call, direct reply. +func TestAgentDirectReply(t *testing.T) { + llm := &mockLLM{responses: []string{"Hello! How can I help you?"}} + a := New(8080, "tok", mockGetLLM(llm), testPrompt) + + reply := a.Run("hello") + + if reply != "Hello! How can I help you?" { + t.Fatalf("unexpected reply: %q", reply) + } + if llm.calls != 1 { + t.Fatalf("expected 1 LLM call, got %d", llm.calls) + } +} + +// TestAgentAPICall: LLM calls API, gets result, gives final reply — two LLM calls. +func TestAgentAPICall(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/my-traders" { + w.Write([]byte(`[{"id":"t1","name":"BTC Strategy"}]`)) + return + } + w.WriteHeader(404) + })) + defer srv.Close() + + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + + llm := &mockLLM{responses: []string{ + `Let me check.{"method":"GET","path":"/api/my-traders","body":{}}`, + "You have one trader: BTC Strategy.", + }} + a := New(port, "tok", mockGetLLM(llm), testPrompt) + + reply := a.Run("list my traders") + + if reply != "You have one trader: BTC Strategy." { + t.Fatalf("unexpected reply: %q", reply) + } + if llm.calls != 2 { + t.Fatalf("expected 2 LLM calls, got %d", llm.calls) + } +} + +// TestAgentMultiStep: LLM chains two API calls before final reply — three LLM calls. +func TestAgentMultiStep(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + + llm := &mockLLM{responses: []string{ + `Checking account.{"method":"GET","path":"/api/account","body":{}}`, + `Now checking positions.{"method":"GET","path":"/api/positions","body":{}}`, + "Account looks healthy and no open positions.", + }} + a := New(port, "tok", mockGetLLM(llm), testPrompt) + + reply := a.Run("show me account status") + + if llm.calls != 3 { + t.Fatalf("expected 3 LLM calls (2 api + 1 final), got %d", llm.calls) + } + if reply != "Account looks healthy and no open positions." { + t.Fatalf("unexpected final reply: %q", reply) + } +} + +// TestAgentAPIResultInContext: API result must appear in next LLM message. +func TestAgentAPIResultInContext(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"balance":1234.56}`)) + })) + defer srv.Close() + + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + + llm := &mockLLM{responses: []string{ + `{"method":"GET","path":"/api/account","body":{}}`, + "Balance is 1234.56 USDT.", + }} + a := New(port, "tok", mockGetLLM(llm), testPrompt) + a.Run("show balance") + + found := false + for _, msg := range llm.lastMsgs { + if strings.Contains(msg.Content, "API result") || strings.Contains(msg.Content, "balance") { + found = true + break + } + } + if !found { + t.Fatalf("API result not found in subsequent LLM context") + } +} + +// TestParseAPICall: unit tests for the XML tag parser. +func TestParseAPICall(t *testing.T) { + t.Run("valid call", func(t *testing.T) { + resp := `Stopping trader.{"method":"POST","path":"/api/traders/t1/stop","body":{}}` + req, text := parseAPICall(resp) + if req == nil { + t.Fatal("expected api_call, got nil") + } + if req.Method != "POST" || req.Path != "/api/traders/t1/stop" { + t.Fatalf("unexpected req: %+v", req) + } + if text != "Stopping trader." { + t.Fatalf("unexpected text before tag: %q", text) + } + }) + + t.Run("no call tag", func(t *testing.T) { + req, text := parseAPICall("Just a reply.") + if req != nil { + t.Fatal("expected nil api_call") + } + if text != "Just a reply." { + t.Fatalf("expected original text, got %q", text) + } + }) + + t.Run("malformed JSON", func(t *testing.T) { + req, _ := parseAPICall(`NOT JSON`) + if req != nil { + t.Fatal("expected nil for malformed JSON") + } + }) +} +``` + +**Step 1: Create the test file** + +**Step 2: Run tests** + +```bash +go test ./telegram/agent/... -v +``` + +Expected: all PASS. + +**Step 3: Commit** + +```bash +git add telegram/agent/agent_test.go +git commit -m "test(telegram/agent): add agent tests with mock HTTP server" +``` + +--- + +## Task 8: Simplify `telegram/session/session.go` + +Replace file content: + +```go +package session + +import ( + "nofx/mcp" + "sync" + "time" +) + +// Session holds conversation memory for a single Telegram chat. +type Session struct { + ChatID int64 + Memory *Memory + UpdatedAt time.Time +} + +func (s *Session) ResetFull() { s.Memory.ResetFull() } + +// Manager manages sessions by chat ID. +type Manager struct { + mu sync.RWMutex + sessions map[int64]*Session + llm mcp.AIClient +} + +func NewManager(llm mcp.AIClient) *Manager { + return &Manager{sessions: make(map[int64]*Session), llm: llm} +} + +func (m *Manager) Get(chatID int64) *Session { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sessions[chatID] + if !ok { + s = &Session{ChatID: chatID, Memory: NewMemory(m.llm), UpdatedAt: time.Now()} + m.sessions[chatID] = s + } + s.UpdatedAt = time.Now() + return s +} +``` + +```bash +go build ./... +git add telegram/session/session.go +git commit -m "refactor(telegram/session): remove intent/params fields" +``` + +--- + +## Task 9: Wire `telegram/bot.go` + +**Step 1: In `runBot`, replace old wiring with:** + +```go +botToken, err := agent.GenerateBotToken() +if err != nil { + logger.Errorf("Failed to generate bot JWT: %v", err) + return false +} +agents := agent.NewManager(cfg.APIServerPort, botToken, + func() mcp.AIClient { return newLLMClient(st) }, + api.GetAPIDocs(), +) +``` + +**Step 2: Replace `/start` reset:** +```go +// old: sessions.Get(chatID).ResetFull() +agents.Reset(chatID) +``` + +**Step 3: Replace message processing:** +```go +go func(chatID int64, text string) { + bot.Send(tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)) //nolint:errcheck + reply := agents.Run(chatID, text) + msg := tgbotapi.NewMessage(chatID, reply) + msg.ParseMode = "Markdown" + if _, err := bot.Send(msg); err != nil { + msg.ParseMode = "" + bot.Send(msg) //nolint:errcheck + } +}(chatID, text) +``` + +**Step 4: Update imports** — remove `service`, `handler`, `intent`, `session`; add `agent`, `api`: + +```go +import ( + "nofx/config" + "nofx/logger" + "nofx/manager" + "nofx/mcp" + "nofx/store" + "nofx/api" + "nofx/telegram/agent" + "os" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) +``` + +**Step 5: Full build** + +```bash +go build ./... +git add telegram/bot.go +git commit -m "feat(telegram): wire agent.Manager with auto-generated API docs" +``` + +--- + +## Task 10: Delete old files + +```bash +git rm telegram/intent/parser.go telegram/handler/handler.go telegram/handler/handler_test.go +rmdir telegram/intent telegram/handler 2>/dev/null || true +go build ./... && go test ./... +git commit -m "refactor(telegram): delete old intent/handler packages" +``` + +--- + +## Task 11: End-to-end verification + +```bash +go test ./telegram/... ./api/... -v -count=1 +go build ./... +``` + +Manual verification — none of these scenarios need any special code: +- [ ] "hello" → natural conversation reply +- [ ] "list my traders" → GET /api/my-traders, formatted reply +- [ ] "show positions" → GET /api/positions +- [ ] "check balance then stop trader if loss > 5%" → multi-step: GET /api/account → POST /api/traders/:id/stop +- [ ] "create a BTC strategy with 5% stop loss" → GET /api/strategies/default-config → POST /api/strategies +- [ ] "show latest trading decisions" → GET /api/decisions/latest +- [ ] "what's the BTC 1h chart looking like" → GET /api/klines?symbol=BTCUSDT&interval=1h +- [ ] "delete trader xxx" → DELETE /api/traders/:id +- [ ] Any unrecognized input → LLM replies naturally, no error diff --git a/docs/plans/2026-03-06-telegram-bot.md b/docs/plans/2026-03-06-telegram-bot.md new file mode 100644 index 00000000..1627dce5 --- /dev/null +++ b/docs/plans/2026-03-06-telegram-bot.md @@ -0,0 +1,1218 @@ +# Telegram Bot Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在 NOFX 单进程内内置 Telegram Bot,用户通过自然语言(LLM 解析意图)在 Telegram 配置策略、交易所、大模型、交易员、查询持仓、控制交易。 + +**Architecture:** 新增 `telegram/` 包,单一 Facade 层(`service/nofx.go`)作为唯一接触 NOFX 内部的边界,借鉴 openclaw compaction 模式实现多轮对话记忆压缩,`main.go` 仅增加 3 行。 + +**Tech Stack:** Go, `github.com/go-telegram-bot-api/telegram-bot-api/v5`(已在 go.mod), `nofx/mcp`(复用现有 LLM 客户端) + +--- + +## 监工修正(Claude 开始前先读) + +这份文档里的代码块只能当伪代码参考,**不能直接照抄**。当前仓库真实接口和文档示例存在多处偏差,首轮实现必须以编译通过的仓库接口为准。 + +### 真实接口约束 + +1. `manager.TraderManager` **没有** `StartTrader` / `StopTrader` 方法。 + - Telegram 启停交易员时,必须复用现有 API Server 的流程语义: + - 启动:校验归属 -> 移除已停止的内存实例 -> `LoadUserTradersFromStore()` -> `GetTrader()` -> `go trader.Run()` -> `store.Trader().UpdateStatus(userID, traderID, true)` + - 停止:`GetTrader()` -> 检查 `GetStatus()["is_running"]` -> `Stop()` -> `UpdateStatus(..., false)` + +2. `store` 方法签名与文档示例不一致,必须按真实接口实现: + - `store.Trader().List(userID)` 返回 `[]*store.Trader` + - `store.Trader()` 没有 `Get(traderID)`,常用的是 `GetFullConfig(userID, traderID)` + - `store.Strategy().Get(userID, id string)`,`Strategy.ID` 是 `string`,不是 `uint` + - `store.AIModel().Create(...)` 返回 `error`,不是 `*store.AIModel` + - `store.Exchange().Create(...)` 返回 `(string, error)`,不是 `*store.Exchange` + - `store.Exchange()` 读单条配置用 `GetByID(userID, id)` + - `store.Equity()` 没有 `Latest`,现有方法是 `GetLatest(traderID, limit)` + - `store.Position()` 没有 `ListByTrader` + +3. `mcp.New()` 在当前仓库中不存在。 + - 必须使用已有构造器,例如 `mcp.NewDeepSeekClient()`、`mcp.NewClient(...)`,或新增一个显式 helper。 + +4. 策略创建不能直接拼一个“猜测字段”的 JSON。 + - 当前真实结构是 `store.StrategyConfig` + - 首选做法:从 `store.GetDefaultStrategyConfig("zh")` 起步,修改需要的字段,再 `json.Marshal` + - `Strategy.ID` 需要像现有 API 一样使用 `uuid.New().String()` + +5. “修改策略 Prompt” 不能按文档示例那样直接改 `Strategy.CustomPrompt`。 + - `store.Strategy` 没有这个顶层字段 + - 真实做法应是:读取 `strategy.Config` -> `ParseConfig()` -> 更新 `StrategyConfig.CustomPrompt` 或相关 prompt section -> 序列化回 `strategy.Config` -> `Update(strategy)` + +6. `/start` 的“完全重置”与当前伪代码冲突。 + - 现在 `Memory.Reset()` 只清空短期历史,不清空长期摘要 + - 如果 `/start` 要“重置会话”,就必须新增 `ClearAll()` 或重建 `Memory` + +7. 不要在 Telegram 回复里默认启用 `Markdown` parse mode。 + - 用户输入、策略名、API key、交易对等都可能包含 Markdown 特殊字符 + - 首版建议纯文本回复,稳定后再做 escape + +8. 不要在日志、回复、错误信息中回显敏感字段。 + - `api_key` + - `secret_key` + - `passphrase` + - 私钥或钱包密钥 + +### 首轮交付范围(必须收敛) + +首个可交付版本只做“最小可用闭环”,不要一口气把所有写操作做满: + +1. 必做: + - Telegram Bot 启动 + - 管理员 chat ID 鉴权 + - `/start` 重置会话 + - 会话管理 + - LLM 意图解析 + - 只读查询:`list traders` / `query positions` / `query equity` + - 控制:`start trader` / `stop trader` + +2. 第二阶段再做: + - `config_strategy` + - `config_exchange` + - `config_model` + - `config_trader` + - `update_prompt` + +3. `control_close` 先不要做,除非先找到仓库里现成且安全的平仓入口。 + +### 硬性门禁 + +1. 每个子任务至少过 `go build ./telegram/...` +2. 合并前必须过 `go build ./...` +3. `handler/` 不允许直接碰 `store/` 或 `manager/` +4. 所有跨层访问都只能从 `telegram/service/nofx.go` 进入 +5. 任何伪代码字段名、方法名、返回值,在落地前都必须先对照真实仓库接口 + +--- + +## 文件结构 + +``` +telegram/ +├── bot.go # 新建:Bot 启动、消息收发路由 +├── session/ +│ ├── session.go # 新建:会话状态(当前意图、进度) +│ └── memory.go # 新建:对话记忆 + 自动压缩 +├── intent/ +│ └── parser.go # 新建:LLM 意图解析 +├── service/ +│ └── nofx.go # 新建:Facade(唯一接触 store/manager 的地方) +└── handler/ + └── handler.go # 新建:业务路由,只调 service/ 和 intent/ + +config/config.go # 修改:加 TelegramBotToken, TelegramAdminChatID +main.go # 修改:加 3 行启动 Telegram Bot +``` + +--- + +### Task 1: 扩展 Config + +**Files:** +- Modify: `config/config.go` + +**Step 1: 在 Config struct 末尾加两个字段** + +```go +// Telegram Bot configuration +TelegramBotToken string // TELEGRAM_BOT_TOKEN +TelegramAdminChatID int64 // TELEGRAM_ADMIN_CHAT_ID (only this user can operate) +``` + +**Step 2: 在 Init() 函数的解析段加读取逻辑** + +找到 Init() 函数中 os.Getenv 的模式,加: + +```go +cfg.TelegramBotToken = os.Getenv("TELEGRAM_BOT_TOKEN") +if chatIDStr := os.Getenv("TELEGRAM_ADMIN_CHAT_ID"); chatIDStr != "" { + if id, err := strconv.ParseInt(chatIDStr, 10, 64); err == nil { + cfg.TelegramAdminChatID = id + } +} +``` + +**监工补充:** `Init()` 函数里当前一直在填充局部变量 `cfg`,最后才赋值给 `global`,这里不能提前写 `global.TelegramBotToken` + +**Step 3: 构建验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./... +``` + +Expected: 无错误 + +**Step 4: Commit** + +```bash +git add config/config.go +git commit -m "feat(telegram): add TelegramBotToken and TelegramAdminChatID to config" +``` + +--- + +### Task 2: Facade 层 telegram/service/nofx.go + +**Files:** +- Create: `telegram/service/nofx.go` + +这是**唯一**接触 NOFX 内部(store、manager)的文件。handler 不直接碰 store/manager。 + +**Step 1: 创建文件** + +```go +package service + +import ( + "fmt" + "nofx/manager" + "nofx/store" +) + +// NofxService is the single facade between Telegram bot and NOFX internals. +// All store/manager access MUST go through this layer. +type NofxService struct { + store *store.Store + manager *manager.TraderManager + userID string // fixed user ID for single-user mode: "default" +} + +func New(st *store.Store, tm *manager.TraderManager) *NofxService { + return &NofxService{store: st, manager: tm, userID: "default"} +} + +// --- Trader --- + +func (s *NofxService) ListTraders() ([]store.Trader, error) { + return s.store.Trader().List(s.userID) +} + +func (s *NofxService) StartTrader(traderID string) error { + t, err := s.store.Trader().Get(traderID) + if err != nil { + return fmt.Errorf("trader not found: %w", err) + } + return s.manager.StartTrader(t, s.store) +} + +func (s *NofxService) StopTrader(traderID string) error { + return s.manager.StopTrader(traderID) +} + +// --- Strategy --- + +func (s *NofxService) ListStrategies() ([]store.Strategy, error) { + return s.store.Strategy().List(s.userID) +} + +func (s *NofxService) CreateStrategy(name string, configJSON string) (*store.Strategy, error) { + strategy := &store.Strategy{ + UserID: s.userID, + Name: name, + Config: configJSON, + } + if err := s.store.Strategy().Create(strategy); err != nil { + return nil, err + } + return strategy, nil +} + +func (s *NofxService) UpdateStrategyPrompt(strategyID uint, prompt string) error { + strategy, err := s.store.Strategy().Get(strategyID) + if err != nil { + return err + } + strategy.CustomPrompt = prompt + return s.store.Strategy().Update(strategy) +} + +// --- AI Model --- + +func (s *NofxService) ListModels() ([]store.AIModel, error) { + return s.store.AIModel().List(s.userID) +} + +func (s *NofxService) CreateModel(provider, apiKey, model string) (*store.AIModel, error) { + m := &store.AIModel{ + UserID: s.userID, + Provider: provider, + APIKey: apiKey, + Model: model, + } + if err := s.store.AIModel().Create(m); err != nil { + return nil, err + } + return m, nil +} + +// --- Exchange --- + +func (s *NofxService) ListExchanges() ([]store.Exchange, error) { + return s.store.Exchange().List(s.userID) +} + +func (s *NofxService) CreateExchange(exchangeType, apiKey, secretKey string) (*store.Exchange, error) { + ex := &store.Exchange{ + UserID: s.userID, + ExchangeType: exchangeType, + APIKey: apiKey, + SecretKey: secretKey, + } + if err := s.store.Exchange().Create(ex); err != nil { + return nil, err + } + return ex, nil +} + +// --- Positions / Query --- + +func (s *NofxService) GetPositions(traderID string) ([]store.TraderPosition, error) { + return s.store.Position().ListByTrader(traderID) +} + +func (s *NofxService) GetEquitySummary(traderID string) (*store.EquitySnapshot, error) { + return s.store.Equity().Latest(traderID) +} +``` + +**Step 2: 注意事项** + +store 的方法名称(List、Get、Create、Update)需要根据实际 store 接口调整。运行 `go build ./telegram/...` 后根据编译错误逐一对齐方法名。 + +**监工补充:这一节不能照抄上面的示例实现,至少要修正以下事实** + +- `ListTraders()` / `ListStrategies()` / `ListModels()` / `ListExchanges()` 的返回值都应与真实 store 一致,当前仓库大多是指针切片 +- `StartTrader()` / `StopTrader()` 不能调用不存在的 `manager` 方法,必须镜像 `api/server.go` 的启动/停止流程 +- `CreateStrategy()` 不能假设 `Strategy.ID` 是整数;请复用现有 API 的 `uuid.New().String()` 方案 +- `CreateModel()` / `CreateExchange()` 不能假设 store 会返回新建对象;真实接口要么返回 `error`,要么返回 `(id, error)` +- `GetPositions()` / `GetEquitySummary()` 需要在 `service` 内封装真实查询逻辑,不能调用仓库中不存在的 `ListByTrader()` / `Latest()` + +**Step 3: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +Expected: 只可能有 store 方法名不匹配的错误,逐一修正即可。 + +**Step 4: Commit** + +```bash +git add telegram/service/nofx.go +git commit -m "feat(telegram): add NofxService facade layer" +``` + +--- + +### Task 3: 会话记忆 telegram/session/memory.go + +**Files:** +- Create: `telegram/session/memory.go` + +借鉴 openclaw compaction 模式:token 超阈值 → LLM 静默压缩 → 写入长期记忆 → 清空短期历史。 + +**Step 1: 创建文件** + +```go +package session + +import ( + "fmt" + "nofx/mcp" + "strings" +) + +const ( + // When short-term history exceeds this token estimate, trigger compaction + compactionThresholdTokens = 3000 + // Rough estimate: 1 token ≈ 4 chars (Chinese ~2 chars/token) + charsPerToken = 3 +) + +// Message represents a single conversation turn +type Message struct { + Role string // "user" or "assistant" + Content string +} + +// Memory manages conversation history with automatic compaction. +// Inspired by openclaw's compaction pattern. +type Memory struct { + LongTerm string // Durable summary (survives compaction) + ShortTerm []Message // Recent conversation (cleared on compaction) + llm mcp.AIClient +} + +func NewMemory(llm mcp.AIClient) *Memory { + return &Memory{llm: llm} +} + +// Add appends a message and triggers compaction if needed +func (m *Memory) Add(role, content string) { + m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content}) + if m.estimateTokens() > compactionThresholdTokens { + m.compact() + } +} + +// BuildContext returns context string for LLM intent parsing +func (m *Memory) BuildContext() string { + var sb strings.Builder + if m.LongTerm != "" { + sb.WriteString("【历史摘要】\n") + sb.WriteString(m.LongTerm) + sb.WriteString("\n\n") + } + if len(m.ShortTerm) > 0 { + sb.WriteString("【近期对话】\n") + for _, msg := range m.ShortTerm { + sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content)) + } + } + return sb.String() +} + +// Reset clears session (called on /start or new session) +func (m *Memory) Reset() { + m.ShortTerm = []Message{} + // LongTerm is preserved intentionally +} + +func (m *Memory) estimateTokens() int { + total := len(m.LongTerm) + for _, msg := range m.ShortTerm { + total += len(msg.Content) + } + return total / charsPerToken +} + +// compact summarizes short-term history into long-term memory (silent, user doesn't see this) +func (m *Memory) compact() { + if m.llm == nil || len(m.ShortTerm) == 0 { + return + } + + history := m.BuildContext() + systemPrompt := `你是一个对话摘要助手。将以下交易配置对话压缩为简洁摘要。 + +必须保留: +- 用户正在配置什么(策略/交易所/大模型/交易员) +- 已确认的参数(交易对、杠杆、止损比例、指标等) +- 待确认或缺失的参数 +- 用户表达的偏好和要求 + +输出格式:纯文本摘要,不超过200字。` + + summary, err := m.llm.CallWithMessages(systemPrompt, history) + if err != nil { + // Compaction failed: keep short-term as-is, don't lose data + return + } + + // Write summary to long-term, clear short-term + if m.LongTerm != "" { + m.LongTerm = m.LongTerm + "\n" + summary + } else { + m.LongTerm = summary + } + m.ShortTerm = []Message{} +} +``` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/session/memory.go +git commit -m "feat(telegram): add conversation memory with openclaw-style compaction" +``` + +--- + +### Task 4: 会话状态 telegram/session/session.go + +**Files:** +- Create: `telegram/session/session.go` + +**Step 1: 创建文件** + +```go +package session + +import ( + "nofx/mcp" + "sync" + "time" +) + +// Intent represents what the user is currently trying to do +type Intent string + +const ( + IntentNone Intent = "" + IntentConfigStrategy Intent = "config_strategy" + IntentConfigExchange Intent = "config_exchange" + IntentConfigModel Intent = "config_model" + IntentConfigTrader Intent = "config_trader" + IntentQueryPositions Intent = "query_positions" + IntentControlTrader Intent = "control_trader" + IntentUpdatePrompt Intent = "update_prompt" +) + +// Session holds state for a single Telegram conversation +type Session struct { + ChatID int64 + Intent Intent + Params map[string]string // collected parameters so far + Memory *Memory + UpdatedAt time.Time +} + +// Manager manages all active sessions (one per chat ID) +type Manager struct { + mu sync.RWMutex + sessions map[int64]*Session + llm mcp.AIClient +} + +func NewManager(llm mcp.AIClient) *Manager { + return &Manager{ + sessions: make(map[int64]*Session), + llm: llm, + } +} + +// Get returns or creates a session for the given chat ID +func (m *Manager) Get(chatID int64) *Session { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[chatID] + if !ok { + s = &Session{ + ChatID: chatID, + Intent: IntentNone, + Params: make(map[string]string), + Memory: NewMemory(m.llm), + UpdatedAt: time.Now(), + } + m.sessions[chatID] = s + } + s.UpdatedAt = time.Now() + return s +} + +// Reset clears session intent and params (keeps memory) +func (s *Session) Reset() { + s.Intent = IntentNone + s.Params = make(map[string]string) +} + +// ResetFull clears everything including memory (on /start command) +func (s *Session) ResetFull() { + s.Reset() + s.Memory.Reset() +} +``` + +**监工补充:这里的伪代码与注释不一致** + +- 当前 `Memory.Reset()` 只清空短期历史,不会清空 `LongTerm` +- 如果 `/start` 的产品语义是“完全重置”,这里必须改成真正清空长期摘要,或者直接新建一个 `Memory` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/session/session.go +git commit -m "feat(telegram): add session state manager" +``` + +--- + +### Task 5: LLM 意图解析 telegram/intent/parser.go + +**Files:** +- Create: `telegram/intent/parser.go` + +复用 `nofx/mcp` 的现有 LLM 客户端,不引入新依赖。 + +**Step 1: 创建文件** + +```go +package intent + +import ( + "encoding/json" + "nofx/mcp" + "strings" +) + +// ParsedIntent is the structured output from LLM intent parsing +type ParsedIntent struct { + Action string `json:"action"` // e.g. "config_strategy", "query_positions" + Params map[string]string `json:"params"` // extracted parameters + Missing []string `json:"missing"` // params still needed + Reply string `json:"reply"` // what bot should say to user +} + +const systemPrompt = `你是 NOFX 交易系统的对话助手。分析用户消息,提取交易配置意图和参数。 + +支持的操作(action): +- config_strategy: 创建/修改策略(需要:name, coins, indicators, max_position_pct, stop_loss_pct) +- config_exchange: 配置交易所(需要:exchange_type, api_key, secret_key) +- config_model: 配置大模型(需要:provider, api_key, model) +- config_trader: 配置交易员(需要:name, model_id, exchange_id, strategy_id) +- query_positions: 查询持仓(需要:trader_id 或 "all") +- query_equity: 查询账户余额/盈亏 +- control_start: 启动交易员(需要:trader_id 或 trader_name) +- control_stop: 停止交易员(需要:trader_id 或 trader_name) +- control_close: 紧急平仓(需要:trader_id, symbol) +- update_prompt: 修改策略 Prompt(需要:strategy_id 或 strategy_name, prompt) +- unknown: 无法识别 + +输出严格 JSON 格式: +{ + "action": "action_name", + "params": {"key": "value"}, + "missing": ["param1", "param2"], + "reply": "对用户的回复(询问缺失参数或确认操作)" +} + +安全要求:API Key 等敏感信息原样保留在 params 中,不要截断或修改。` + +// Parser uses LLM to parse user message into structured intent +type Parser struct { + llm mcp.AIClient +} + +func NewParser(llm mcp.AIClient) *Parser { + return &Parser{llm: llm} +} + +// Parse sends user message + conversation context to LLM, returns structured intent +func (p *Parser) Parse(userMessage, conversationContext string) (*ParsedIntent, error) { + userPrompt := userMessage + if conversationContext != "" { + userPrompt = conversationContext + "\n\n【当前消息】\n" + userMessage + } + + resp, err := p.llm.CallWithMessages(systemPrompt, userPrompt) + if err != nil { + return nil, err + } + + // Extract JSON from response (LLM may wrap in markdown code block) + jsonStr := extractJSON(resp) + + var result ParsedIntent + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + // Fallback: return unknown intent with raw response as reply + return &ParsedIntent{ + Action: "unknown", + Reply: "抱歉,我没有理解你的意思。请描述你想做什么,例如:「帮我创建一个 BTC 策略」", + }, nil + } + return &result, nil +} + +func extractJSON(s string) string { + // Strip markdown code block if present + s = strings.TrimSpace(s) + if idx := strings.Index(s, "```json"); idx >= 0 { + s = s[idx+7:] + } else if idx := strings.Index(s, "```"); idx >= 0 { + s = s[idx+3:] + } + if idx := strings.LastIndex(s, "```"); idx >= 0 { + s = s[:idx] + } + // Find first { to last } + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start >= 0 && end > start { + return s[start : end+1] + } + return s +} +``` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/intent/parser.go +git commit -m "feat(telegram): add LLM intent parser" +``` + +--- + +### Task 6: 业务处理 telegram/handler/handler.go + +**Files:** +- Create: `telegram/handler/handler.go` + +handler 只调 service/ 和 intent/,不直接碰 store/manager。 + +**Step 1: 创建文件** + +```go +package handler + +import ( + "fmt" + "nofx/telegram/intent" + "nofx/telegram/service" + "nofx/telegram/session" + "strings" +) + +// Handler dispatches parsed intents to the right operation +type Handler struct { + svc *service.NofxService + parser *intent.Parser + sessions *session.Manager +} + +func New(svc *service.NofxService, parser *intent.Parser, sessions *session.Manager) *Handler { + return &Handler{svc: svc, parser: parser, sessions: sessions} +} + +// Handle processes a user message and returns the bot reply +func (h *Handler) Handle(chatID int64, userMessage string) string { + sess := h.sessions.Get(chatID) + + // Record user message in memory + sess.Memory.Add("user", userMessage) + + // Build conversation context for LLM + ctx := sess.Memory.BuildContext() + + // Parse intent via LLM + parsed, err := h.parser.Parse(userMessage, ctx) + if err != nil { + return "❌ 解析失败,请重试" + } + + // Merge newly extracted params into session + for k, v := range parsed.Params { + sess.Params[k] = v + } + + // If there are missing params, ask user + if len(parsed.Missing) > 0 { + sess.Intent = session.Intent(parsed.Action) + reply := parsed.Reply + sess.Memory.Add("assistant", reply) + return reply + } + + // Execute the action + reply := h.execute(sess, parsed) + sess.Memory.Add("assistant", reply) + sess.Reset() // clear intent after successful execution + return reply +} + +func (h *Handler) execute(sess *session.Session, parsed *intent.ParsedIntent) string { + params := sess.Params + + switch parsed.Action { + case "config_strategy": + return h.createStrategy(params) + + case "config_exchange": + return h.createExchange(params) + + case "config_model": + return h.createModel(params) + + case "query_positions": + return h.queryPositions(params) + + case "query_equity": + return h.queryEquity(params) + + case "control_start": + return h.startTrader(params) + + case "control_stop": + return h.stopTrader(params) + + case "update_prompt": + return h.updatePrompt(params) + + default: + return parsed.Reply + } +} + +func (h *Handler) createStrategy(params map[string]string) string { + name := params["name"] + if name == "" { + name = "我的策略" + } + // Build a minimal strategy config JSON from params + // Full StrategyConfig is complex; we start with essential fields + configJSON := buildStrategyConfigJSON(params) + strategy, err := h.svc.CreateStrategy(name, configJSON) + if err != nil { + return fmt.Sprintf("❌ 创建策略失败: %v", err) + } + return fmt.Sprintf("✅ 策略「%s」已创建(ID: %d)\n\n配置摘要:\n%s", strategy.Name, strategy.ID, formatParams(params)) +} + +func (h *Handler) createExchange(params map[string]string) string { + exType := params["exchange_type"] + apiKey := params["api_key"] + secretKey := params["secret_key"] + ex, err := h.svc.CreateExchange(exType, apiKey, secretKey) + if err != nil { + return fmt.Sprintf("❌ 配置交易所失败: %v", err) + } + return fmt.Sprintf("✅ %s 交易所已配置(ID: %d)", ex.ExchangeType, ex.ID) +} + +func (h *Handler) createModel(params map[string]string) string { + provider := params["provider"] + apiKey := params["api_key"] + model := params["model"] + m, err := h.svc.CreateModel(provider, apiKey, model) + if err != nil { + return fmt.Sprintf("❌ 配置大模型失败: %v", err) + } + return fmt.Sprintf("✅ %s (%s) 已配置(ID: %d)", m.Provider, m.Model, m.ID) +} + +func (h *Handler) queryPositions(params map[string]string) string { + traderID := params["trader_id"] + if traderID == "" { + traders, err := h.svc.ListTraders() + if err != nil || len(traders) == 0 { + return "❌ 没有找到交易员" + } + traderID = traders[0].ID + } + positions, err := h.svc.GetPositions(traderID) + if err != nil { + return fmt.Sprintf("❌ 查询持仓失败: %v", err) + } + if len(positions) == 0 { + return "📭 当前无持仓" + } + var sb strings.Builder + sb.WriteString("📊 当前持仓:\n") + for _, p := range positions { + sb.WriteString(fmt.Sprintf("• %s %s | 入场: %.4f | 未实现P&L: %.2f USDT\n", + p.Symbol, p.Side, p.EntryPrice, p.UnrealizedPnl)) + } + return sb.String() +} + +func (h *Handler) queryEquity(params map[string]string) string { + traders, err := h.svc.ListTraders() + if err != nil || len(traders) == 0 { + return "❌ 没有找到交易员" + } + traderID := params["trader_id"] + if traderID == "" { + traderID = traders[0].ID + } + eq, err := h.svc.GetEquitySummary(traderID) + if err != nil { + return fmt.Sprintf("❌ 查询余额失败: %v", err) + } + return fmt.Sprintf("💰 账户余额:%.2f USDT", eq.TotalBalance) +} + +func (h *Handler) startTrader(params map[string]string) string { + traderID := params["trader_id"] + if err := h.svc.StartTrader(traderID); err != nil { + return fmt.Sprintf("❌ 启动失败: %v", err) + } + return "✅ 交易员已启动" +} + +func (h *Handler) stopTrader(params map[string]string) string { + traderID := params["trader_id"] + if err := h.svc.StopTrader(traderID); err != nil { + return fmt.Sprintf("❌ 停止失败: %v", err) + } + return "✅ 交易员已停止" +} + +func (h *Handler) updatePrompt(params map[string]string) string { + // strategy_id must be numeric; convert from params + strategyIDStr := params["strategy_id"] + var strategyID uint + fmt.Sscanf(strategyIDStr, "%d", &strategyID) + prompt := params["prompt"] + if err := h.svc.UpdateStrategyPrompt(strategyID, prompt); err != nil { + return fmt.Sprintf("❌ 更新 Prompt 失败: %v", err) + } + return "✅ 策略 Prompt 已更新" +} + +// buildStrategyConfigJSON builds a minimal valid StrategyConfig JSON from params +func buildStrategyConfigJSON(params map[string]string) string { + coins := params["coins"] + if coins == "" { + coins = "BTC" + } + stopLoss := params["stop_loss_pct"] + if stopLoss == "" { + stopLoss = "5" + } + maxPos := params["max_position_pct"] + if maxPos == "" { + maxPos = "20" + } + indicators := params["indicators"] + + return fmt.Sprintf(`{ + "strategy_type": "ai_trading", + "coin_source": {"source_type": "static", "static_coins": [%q]}, + "indicators": {"enable_rsi": %v, "enable_macd": %v}, + "risk_control": {"stop_loss_pct": %s, "max_position_pct": %s} + }`, + coins, + strings.Contains(indicators, "RSI"), + strings.Contains(indicators, "MACD"), + stopLoss, + maxPos, + ) +} + +func formatParams(params map[string]string) string { + var sb strings.Builder + for k, v := range params { + if k == "api_key" || k == "secret_key" { + v = "***" + } + sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) + } + return sb.String() +} +``` + +**监工补充:这里至少有 6 个会直接出错或行为错误的点** + +1. 当前写法会把“当前消息”重复注入 LLM 上下文。 + - `sess.Memory.Add("user", userMessage)` 已经把本轮消息写进历史 + - `parser.Parse(userMessage, ctx)` 又会把 `userMessage` 拼到 `conversationContext` 后面 + - 二选一修正:要么先 parse 再写 memory,要么 `Parse()` 不再重复追加当前消息 + +2. `store.TraderPosition` 没有 `UnrealizedPnl` 字段。 + - 首版查询持仓只能返回仓位基础信息,或另找真实未实现盈亏来源 + +3. `store.EquitySnapshot` 没有 `TotalBalance` 字段,真实字段是 `TotalEquity` + +4. `strategy.ID` 不是 `%d`,`AIModel` 也没有示例中的 `Model` 字段 + +5. `buildStrategyConfigJSON()` 示例不符合当前仓库真实 `StrategyConfig` + - `risk_control.stop_loss_pct` + - `risk_control.max_position_pct` + 这些都不是当前结构里的真实字段名 + - 首版如果做策略写入,必须基于 `store.GetDefaultStrategyConfig("zh")` 组装 + +6. `updatePrompt()` 不能直接调用“按数值 strategyID 更新顶层 prompt”的假接口 + - 真实实现应该更新 `Strategy.Config` 里的 `CustomPrompt` 或 prompt sections + - 或者先把首版 prompt 修改目标收缩为 `Trader().UpdateCustomPrompt(...)` + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/handler/handler.go +git commit -m "feat(telegram): add intent handler with 6 feature areas" +``` + +--- + +### Task 7: Bot 入口 telegram/bot.go + +**Files:** +- Create: `telegram/bot.go` + +**Step 1: 创建文件** + +```go +package telegram + +import ( + "nofx/config" + "nofx/logger" + "nofx/manager" + "nofx/mcp" + "nofx/store" + "nofx/telegram/handler" + "nofx/telegram/intent" + "nofx/telegram/service" + "nofx/telegram/session" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// Start initializes and runs the Telegram bot. +// Called from main.go as a goroutine. +func Start(cfg *config.Config, st *store.Store, tm *manager.TraderManager) { + if cfg.TelegramBotToken == "" { + logger.Info("📵 Telegram bot not configured (TELEGRAM_BOT_TOKEN not set), skipping") + return + } + + bot, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) + if err != nil { + logger.Errorf("❌ Failed to start Telegram bot: %v", err) + return + } + + logger.Infof("🤖 Telegram bot started: @%s", bot.Self.UserName) + + // Build the LLM client for intent parsing (use DeepSeek by default, same as backtest) + llmClient := mcp.New() + // Configure with whatever key is available in env (intent parsing is lightweight) + // The service layer will use store to get user-configured models for actual trading + + svc := service.New(st, tm) + parser := intent.NewParser(llmClient) + sessions := session.NewManager(llmClient) + h := handler.New(svc, parser, sessions) + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + updates := bot.GetUpdatesChan(u) + + for update := range updates { + if update.Message == nil { + continue + } + + chatID := update.Message.Chat.ID + + // Access control: only allow configured admin chat ID + if cfg.TelegramAdminChatID != 0 && chatID != cfg.TelegramAdminChatID { + msg := tgbotapi.NewMessage(chatID, "⛔ 未授权访问") + bot.Send(msg) + continue + } + + text := update.Message.Text + if text == "" { + continue + } + + // Handle /start command + if text == "/start" { + sessions.Get(chatID).ResetFull() + reply := tgbotapi.NewMessage(chatID, welcomeMessage()) + bot.Send(reply) + continue + } + + // Process message + reply := h.Handle(chatID, text) + msg := tgbotapi.NewMessage(chatID, reply) + msg.ParseMode = "Markdown" + bot.Send(msg) + } +} + +func welcomeMessage() string { + return `👋 欢迎使用 NOFX 交易助手! + +你可以用自然语言配置和管理你的交易系统: + +📋 *配置功能* +• 「帮我创建一个 BTC 策略,RSI+MACD,止损 8%」 +• 「配置 Binance 交易所」 +• 「添加 DeepSeek 大模型」 +• 「创建一个交易员」 + +📊 *查询功能* +• 「查看当前持仓」 +• 「查看账户余额」 + +⚙️ *控制功能* +• 「启动交易员」 +• 「停止交易员」 +• 「修改策略 Prompt」 + +输入 /start 重置会话` +} +``` + +**监工补充:本节伪代码需要先修正两个问题** + +1. `mcp.New()` 在当前仓库里不存在,必须改成真实可用的构造器 +2. `msg.ParseMode = "Markdown"` 首版不要开,先用纯文本,避免用户内容触发格式错误或意外转义 + +**Step 2: Build 验证** + +```bash +cd /Users/yida/gopro/open-nofx && go build ./telegram/... +``` + +**Step 3: Commit** + +```bash +git add telegram/bot.go +git commit -m "feat(telegram): add Telegram bot entry point with access control" +``` + +--- + +### Task 8: 接入 main.go(3 行改动) + +**Files:** +- Modify: `main.go` + +**Step 1: 加 import** + +在 main.go 的 import 块加: + +```go +"nofx/telegram" +``` + +**Step 2: 在 API Server 启动之后加 3 行** + +找到这段代码: +```go +// Start API server +server := api.NewServer(...) +go func() { ... }() +``` + +在其后加: + +```go +// Start Telegram bot (if configured) +go telegram.Start(cfg, st, traderManager) +logger.Info("🤖 Telegram bot goroutine started") +``` + +**Step 3: 完整构建** + +```bash +cd /Users/yida/gopro/open-nofx && go build -o nofx . +``` + +Expected: 成功编译,无错误 + +**Step 4: Commit** + +```bash +git add main.go +git commit -m "feat(telegram): wire Telegram bot into main startup (3 lines)" +``` + +--- + +### Task 9: .env.example 文档更新 + +**Files:** +- Modify: `.env.example` 或 `.env`(若存在) + +**Step 1: 在 .env.example 末尾加** + +```env +# Telegram Bot Configuration +# Get token from @BotFather on Telegram +TELEGRAM_BOT_TOKEN= +# Get your chat ID from @userinfobot on Telegram +TELEGRAM_ADMIN_CHAT_ID= +``` + +**Step 2: Commit** + +```bash +git add .env.example +git commit -m "docs: add Telegram bot configuration to .env.example" +``` + +--- + +### Task 10: 手动集成测试 + +**Step 1: 配置环境变量** + +```bash +export TELEGRAM_BOT_TOKEN=你的bot_token +export TELEGRAM_ADMIN_CHAT_ID=你的chat_id +``` + +**Step 2: 启动 NOFX** + +```bash +cd /Users/yida/gopro/open-nofx && ./nofx +``` + +Expected 日志: +``` +✅ Configuration loaded +🤖 Telegram bot started: @your_bot_name +✅ System started successfully +``` + +**Step 3: 测试对话流程** + +在 Telegram 发送: +1. `/start` → 收到欢迎消息 +2. `查看当前持仓` → 返回持仓信息或「无持仓」 +3. `帮我创建一个 BTC 策略,RSI+MACD,止损 8%` → Bot 追问策略名 +4. `叫"主力BTC"` → 策略创建成功 + +**Step 4: 验证访问控制** + +用其他账号发送消息 → 收到「⛔ 未授权访问」 + +--- + +## 关键约束备忘 + +1. **`service/nofx.go` 是唯一接触 store/manager 的文件**,handler 不能绕过它 +2. **compaction 静默发生**,用户看不到压缩过程 +3. **LLM 客户端必须使用真实存在的构造器**,不能写 `mcp.New()` +4. **当前仓库的 `store` / `manager` 接口与本文示例存在偏差**,实现时必须以源码为准 +5. **首轮目标是“最小可用闭环”而不是功能铺满**,先交付查询与启停,再扩到配置写入 + +## 监工验收清单 + +1. `go build ./telegram/...` 成功 +2. `go build ./...` 成功 +3. 未授权 chat 收到拒绝消息,且不会进入业务逻辑 +4. `/start` 后会话状态确实被清空,且重置语义与代码一致 +5. 启动/停止交易员的行为与现有 HTTP API 一致 +6. 没有任何日志或回复泄露密钥、私钥、passphrase +7. 查询接口用到的字段名全部来自真实 struct,而不是文档猜测 + +## 后续可扩展 + +- 主动推送:NOFX 交易决策 → 推送到 Telegram +- 多语言:intent parser 的 systemPrompt 支持英文 +- 图表:发送持仓/权益曲线截图(需 TradingView Lightweight Charts 截图服务) diff --git a/main.go b/main.go index a0987f81..5758d4fb 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "nofx/manager" "nofx/mcp" "nofx/store" + "nofx/telegram" "os" "os/signal" "path/filepath" @@ -130,12 +131,21 @@ func main() { // Start API server server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort) + + // Create hot-reload channel for Telegram bot; wire it to the API server + // so that POST /api/telegram can trigger a bot restart when the token changes. + telegramReloadCh := make(chan struct{}, 1) + server.SetTelegramReloadCh(telegramReloadCh) + go func() { if err := server.Start(); err != nil { logger.Fatalf("❌ Failed to start API server: %v", err) } }() + // Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured) + go telegram.Start(cfg, st, telegramReloadCh) + // Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) diff --git a/mcp/blockrun_base.go b/mcp/blockrun_base.go index 89ecf561..c97efa4a 100644 --- a/mcp/blockrun_base.go +++ b/mcp/blockrun_base.go @@ -1,14 +1,12 @@ package mcp import ( - "bytes" "crypto/ecdsa" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" - "io" "math/big" "net/http" "strings" @@ -97,120 +95,24 @@ func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customMo } } -func (c *BlockRunBaseClient) setAuthHeader(reqHeaders http.Header) { - // No Bearer token — payment is via x402 signing -} +func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) } -// call overrides the base call to handle HTTP 402 x402 v2 payment flow. func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) { - c.logger.Infof("📡 [BlockRun Base] Request AI Server: %s", c.BaseURL) - - requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt) - jsonData, err := c.hooks.marshalRequestBody(requestBody) - if err != nil { - return "", err - } - - url := c.hooks.buildUrl() - req, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Handle x402 v2 Payment Required - if resp.StatusCode == http.StatusPaymentRequired { - paymentHeader := resp.Header.Get("X-Payment-Required") - if paymentHeader == "" { - return "", fmt.Errorf("received 402 but no X-Payment-Required header") - } - - paymentSig, err := c.signPayment(paymentHeader) - if err != nil { - return "", fmt.Errorf("failed to sign x402 payment: %w", err) - } - - req2, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to build retry request: %w", err) - } - req2.Header.Set("X-Payment", paymentSig) - - resp2, err := c.httpClient.Do(req2) - if err != nil { - return "", fmt.Errorf("failed to send payment retry: %w", err) - } - defer resp2.Body.Close() - - body2, err := io.ReadAll(resp2.Body) - if err != nil { - return "", fmt.Errorf("failed to read payment retry response: %w", err) - } - if resp2.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun payment retry failed (status %d): %s", resp2.StatusCode, string(body2)) - } - return c.hooks.parseMCPResponse(body2) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun API error (status %d): %s", resp.StatusCode, string(body)) - } - return c.hooks.parseMCPResponse(body) + return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt) } -// x402v2PaymentRequired is the structure of the X-Payment-Required header (x402 v2). -type x402v2PaymentRequired struct { - X402Version int `json:"x402Version"` - Accepts []struct { - Scheme string `json:"scheme"` - Network string `json:"network"` - Amount string `json:"amount"` - Asset string `json:"asset"` - PayTo string `json:"payTo"` - MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` - Extra map[string]string `json:"extra"` - } `json:"accepts"` - Resource *struct { - URL string `json:"url"` - Description string `json:"description"` - MimeType string `json:"mimeType"` - } `json:"resource"` +func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) { + return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req) } -// signPayment parses the X-Payment-Required header (x402 v2) and returns a signed X-Payment value. +// signPayment parses the Payment-Required header (x402 v2) and returns a signed payment value. func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) { - if c.privateKey == nil { - return "", fmt.Errorf("no private key set for BlockRun Base wallet") - } + return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base") +} - // Decode base64 → JSON - decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - return "", fmt.Errorf("failed to base64-decode payment header: %w", err) - } - } - - var req x402v2PaymentRequired - if err := json.Unmarshal(decoded, &req); err != nil { - return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err) - } - - if len(req.Accepts) == 0 { - return "", fmt.Errorf("no payment options in x402 response") - } - - opt := req.Accepts[0] +// signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC. +// Used by both BlockRunBaseClient and Claw402Client. +func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402AcceptOption, resource *x402Resource) (string, error) { recipient := opt.PayTo amount := opt.Amount network := opt.Network @@ -224,28 +126,22 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error resourceURL := "" resourceDesc := "" resourceMime := "application/json" - if req.Resource != nil { - resourceURL = req.Resource.URL - resourceDesc = req.Resource.Description - resourceMime = req.Resource.MimeType + if resource != nil { + resourceURL = resource.URL + resourceDesc = resource.Description + resourceMime = resource.MimeType } - // Timestamps: validAfter = now-600 (clock skew), validBefore = now+maxTimeout now := time.Now().Unix() - validAfter := now - 600 + validAfter := int64(0) validBefore := now + int64(maxTimeout) - // Random nonce (bytes32) nonceBytes := make([]byte, 32) if _, err := rand.Read(nonceBytes); err != nil { return "", fmt.Errorf("failed to generate nonce: %w", err) } nonce := "0x" + hex.EncodeToString(nonceBytes) - // Sender address - senderAddr := crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex() - - // Build EIP-712 domain separator domainName := "USD Coin" domainVersion := "2" if extra != nil { @@ -262,7 +158,6 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error return "", fmt.Errorf("failed to build domain separator: %w", err) } - // Build struct hash amountBig, err := parseBigInt(amount) if err != nil { return "", fmt.Errorf("invalid amount: %w", err) @@ -273,26 +168,22 @@ func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error return "", fmt.Errorf("failed to build struct hash: %w", err) } - // EIP-712 digest digest := make([]byte, 0, 66) digest = append(digest, 0x19, 0x01) digest = append(digest, domainSeparator...) digest = append(digest, structHash...) hash := keccak256Bytes(digest) - // Sign with secp256k1 - sig, err := crypto.Sign(hash, c.privateKey) + sig, err := crypto.Sign(hash, privateKey) if err != nil { return "", fmt.Errorf("failed to sign: %w", err) } - // Adjust V: go-ethereum returns 0/1, EIP-712 expects 27/28 if sig[64] < 27 { sig[64] += 27 } sigHex := "0x" + hex.EncodeToString(sig) - // Build x402 v2 payment payload paymentData := map[string]interface{}{ "x402Version": 2, "resource": map[string]string{ @@ -419,10 +310,14 @@ func hexToBytes32(s string) ([]byte, error) { } func parseBigInt(s string) (*big.Int, error) { - s = strings.TrimPrefix(s, "0x") n := new(big.Int) - if _, ok := n.SetString(s, 16); ok { - return n, nil + // Only treat as hex when explicitly prefixed with 0x/0X. + // x402 amounts are always decimal strings (e.g. "3000" = 0.003 USDC). + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + if _, ok := n.SetString(s[2:], 16); ok { + return n, nil + } + return nil, fmt.Errorf("cannot parse hex big.Int from %q", s) } if _, ok := n.SetString(s, 10); ok { return n, nil @@ -445,12 +340,6 @@ func (c *BlockRunBaseClient) buildUrl() string { return DefaultBlockRunBaseURL + BlockRunChatEndpoint } -// buildRequest creates the HTTP request without an Authorization header. func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) { - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("fail to build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - return req, nil + return x402BuildRequest(url, jsonData) } diff --git a/mcp/blockrun_sol.go b/mcp/blockrun_sol.go index 03dab8ec..cb88636e 100644 --- a/mcp/blockrun_sol.go +++ b/mcp/blockrun_sol.go @@ -1,12 +1,10 @@ package mcp import ( - "bytes" "context" "encoding/base64" "encoding/json" "fmt" - "io" "net/http" "strings" @@ -76,120 +74,34 @@ func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customMod } } -func (c *BlockRunSolClient) setAuthHeader(reqHeaders http.Header) { - // No Bearer token — payment is via x402 signing -} +func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) } -// call overrides the base call to handle HTTP 402 x402 v2 Solana payment flow. func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) { - c.logger.Infof("📡 [BlockRun Sol] Request AI Server: %s", c.BaseURL) - - requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt) - jsonData, err := c.hooks.marshalRequestBody(requestBody) - if err != nil { - return "", err - } - - url := c.hooks.buildUrl() - req, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // Handle x402 v2 Payment Required - if resp.StatusCode == http.StatusPaymentRequired { - paymentHeader := resp.Header.Get("X-Payment-Required") - if paymentHeader == "" { - return "", fmt.Errorf("received 402 but no X-Payment-Required header") - } - - paymentSig, err := c.signSolanaPayment(paymentHeader) - if err != nil { - return "", fmt.Errorf("failed to sign Solana x402 payment: %w", err) - } - - req2, err := c.hooks.buildRequest(url, jsonData) - if err != nil { - return "", fmt.Errorf("failed to build retry request: %w", err) - } - req2.Header.Set("X-Payment", paymentSig) - - resp2, err := c.httpClient.Do(req2) - if err != nil { - return "", fmt.Errorf("failed to send payment retry: %w", err) - } - defer resp2.Body.Close() - - body2, err := io.ReadAll(resp2.Body) - if err != nil { - return "", fmt.Errorf("failed to read payment retry response: %w", err) - } - if resp2.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun Sol payment retry failed (status %d): %s", resp2.StatusCode, string(body2)) - } - return c.hooks.parseMCPResponse(body2) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("BlockRun Sol API error (status %d): %s", resp.StatusCode, string(body)) - } - return c.hooks.parseMCPResponse(body) + return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt) } -// solanaPaymentOption is an entry in the accepts[] array of the x402 v2 response. -type solanaPaymentOption struct { - Scheme string `json:"scheme"` - Network string `json:"network"` - Amount string `json:"amount"` - Asset string `json:"asset"` - PayTo string `json:"payTo"` - MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` - Extra map[string]string `json:"extra"` +func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) { + return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req) } -// x402v2SolanaRequired is the parsed X-Payment-Required header for Solana. -type x402v2SolanaRequired struct { - X402Version int `json:"x402Version"` - Accepts []solanaPaymentOption `json:"accepts"` - Resource *struct { - URL string `json:"url"` - Description string `json:"description"` - MimeType string `json:"mimeType"` - } `json:"resource"` -} - -// signSolanaPayment parses the X-Payment-Required header and builds a signed x402 v2 Solana payload. +// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload. func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) { if c.keypair == nil { return "", fmt.Errorf("no private key set for BlockRun Sol wallet") } - // Decode base64 → JSON - decoded, err := base64.RawStdEncoding.DecodeString(paymentHeaderB64) + decoded, err := x402DecodeHeader(paymentHeaderB64) if err != nil { - decoded, err = base64.StdEncoding.DecodeString(paymentHeaderB64) - if err != nil { - return "", fmt.Errorf("failed to base64-decode payment header: %w", err) - } + return "", err } - var req x402v2SolanaRequired + var req x402v2PaymentRequired if err := json.Unmarshal(decoded, &req); err != nil { return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err) } // Find the Solana option - var opt *solanaPaymentOption + var opt *x402AcceptOption for i := range req.Accepts { if strings.HasPrefix(req.Accepts[i].Network, "solana:") { opt = &req.Accepts[i] @@ -360,12 +272,6 @@ func (c *BlockRunSolClient) buildUrl() string { return DefaultBlockRunSolURL + BlockRunChatEndpoint } -// buildRequest creates the HTTP request without an Authorization header. func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) { - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("fail to build request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - return req, nil + return x402BuildRequest(url, jsonData) } diff --git a/mcp/claude_client.go b/mcp/claude_client.go index 6b277e4a..d91bdb81 100644 --- a/mcp/claude_client.go +++ b/mcp/claude_client.go @@ -1,3 +1,19 @@ +// Package mcp — ClaudeClient implements the Anthropic Messages API. +// +// Wire-format differences from the OpenAI-compatible base Client: +// +// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐ +// │ Concept │ OpenAI format │ Anthropic format │ +// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤ +// │ Endpoint │ /v1/chat/completions │ /v1/messages │ +// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │ +// │ System prompt │ messages[0] role=system │ top-level "system" field │ +// │ Tool definition │ type=function + parameters │ name + description + input_schema│ +// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │ +// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │ +// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │ +// │ Max tokens │ max_tokens │ max_tokens (same) │ +// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘ package mcp import ( @@ -12,75 +28,64 @@ const ( DefaultClaudeModel = "claude-opus-4-6" ) +// ClaudeClient wraps the base Client and overrides the methods that differ +// for the Anthropic Messages API. All other behaviour (retry, timeout, +// logging) is inherited unchanged. type ClaudeClient struct { *Client } -// NewClaudeClient creates Claude client (backward compatible) +// NewClaudeClient creates a ClaudeClient with default settings. func NewClaudeClient() AIClient { return NewClaudeClientWithOptions() } -// NewClaudeClientWithOptions creates Claude client (supports options pattern) +// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides. func NewClaudeClientWithOptions(opts ...ClientOption) AIClient { - // 1. Create Claude preset options - claudeOpts := []ClientOption{ + baseClient := NewClient(append([]ClientOption{ WithProvider(ProviderClaude), WithModel(DefaultClaudeModel), WithBaseURL(DefaultClaudeBaseURL), - } + }, opts...)...).(*Client) - // 2. Merge user options (user options have higher priority) - allOpts := append(claudeOpts, opts...) - - // 3. Create base client - baseClient := NewClient(allOpts...).(*Client) - - // 4. Create Claude client - claudeClient := &ClaudeClient{ - Client: baseClient, - } - - // 5. Set hooks to point to ClaudeClient (implement dynamic dispatch) - baseClient.hooks = claudeClient - - return claudeClient + c := &ClaudeClient{Client: baseClient} + baseClient.hooks = c // wire dynamic dispatch to ClaudeClient + return c } -func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) { - c.APIKey = apiKey +// ── Hook overrides ──────────────────────────────────────────────────────────── +// SetAPIKey stores credentials and optional custom endpoint / model. +func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) { + c.APIKey = apiKey if len(apiKey) > 8 { c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:]) } if customURL != "" { c.BaseURL = customURL - c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL) - } else { - c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL) + c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL) } if customModel != "" { c.Model = customModel - c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel) - } else { - c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model) + c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel) } } -// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer -func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) { - reqHeaders.Set("x-api-key", c.APIKey) - reqHeaders.Set("anthropic-version", "2023-06-01") +// setAuthHeader uses x-api-key instead of Authorization: Bearer. +func (c *ClaudeClient) setAuthHeader(h http.Header) { + h.Set("x-api-key", c.APIKey) + h.Set("anthropic-version", "2023-06-01") } -// buildUrl Claude uses /messages endpoint +// buildUrl targets /messages instead of /chat/completions. func (c *ClaudeClient) buildUrl() string { return fmt.Sprintf("%s/messages", c.BaseURL) } -// buildMCPRequestBody Claude has different request format +// buildMCPRequestBody builds the Anthropic wire format for the simple +// CallWithMessages path (no tool support). func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { - requestBody := map[string]any{ + return map[string]any{ "model": c.Model, "max_tokens": c.MaxTokens, "system": systemPrompt, @@ -88,16 +93,175 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[ {"role": "user", "content": userPrompt}, }, } - - return requestBody } -// parseMCPResponse Claude has different response format +// buildRequestBodyFromRequest converts a *Request into the Anthropic Messages +// API wire format. This is the key override that makes tool calling work +// correctly with Claude. +// +// Conversions applied: +// +// - System messages are lifted to the top-level "system" field. +// - Tool definitions: parameters → input_schema, wrapper removed. +// - Assistant messages with ToolCalls → content[{type:tool_use,...}]. +// - Tool result messages (role=tool) → role=user with tool_result blocks. +// Consecutive tool results are merged into a single user turn (Anthropic +// requires strictly alternating user/assistant turns). +// - tool_choice "auto"/"any" → {"type":"auto"/"any"} object. +func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any { + // ── 1. Separate system prompt from conversation messages ────────────────── + var systemPrompt string + var convMsgs []Message + for _, m := range req.Messages { + if m.Role == "system" { + systemPrompt = m.Content + } else { + convMsgs = append(convMsgs, m) + } + } + + // ── 2. Convert messages to Anthropic format ─────────────────────────────── + anthropicMsgs := convertMessagesToAnthropic(convMsgs) + + // ── 3. Convert tool definitions (parameters → input_schema) ────────────── + var anthropicTools []map[string]any + for _, t := range req.Tools { + anthropicTools = append(anthropicTools, map[string]any{ + "name": t.Function.Name, + "description": t.Function.Description, + "input_schema": t.Function.Parameters, + }) + } + + // ── 4. Assemble request body ────────────────────────────────────────────── + body := map[string]any{ + "model": req.Model, + "max_tokens": c.MaxTokens, + "system": systemPrompt, + "messages": anthropicMsgs, + } + + if len(anthropicTools) > 0 { + body["tools"] = anthropicTools + } + + // tool_choice: Anthropic uses an object, not a string. + switch req.ToolChoice { + case "auto": + body["tool_choice"] = map[string]any{"type": "auto"} + case "any": + body["tool_choice"] = map[string]any{"type": "any"} + case "none", "": + // omit — no tool_choice sent + } + + if req.Temperature != nil { + body["temperature"] = *req.Temperature + } + + return body +} + +// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message +// slice to Anthropic's messages array. +// +// Rules: +// 1. role=assistant + ToolCalls → role=assistant, content=[tool_use, ...] +// 2. role=tool (result) → role=user, content=[tool_result, ...] +// Consecutive tool-result messages are merged into one user turn so the +// conversation always alternates user/assistant. +// 3. All other messages → {role, content} as-is. +func convertMessagesToAnthropic(msgs []Message) []map[string]any { + var out []map[string]any + + for i := 0; i < len(msgs); { + msg := msgs[i] + + switch { + // ── Assistant message carrying tool calls ───────────────────────────── + case msg.Role == "assistant" && len(msg.ToolCalls) > 0: + var blocks []map[string]any + for _, tc := range msg.ToolCalls { + // Arguments are a JSON string; Claude wants a parsed object. + var input map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil { + input = map[string]any{"_raw": tc.Function.Arguments} + } + blocks = append(blocks, map[string]any{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Function.Name, + "input": input, + }) + } + out = append(out, map[string]any{ + "role": "assistant", + "content": blocks, + }) + i++ + + // ── Tool result message(s) → single user turn ───────────────────────── + case msg.Role == "tool": + // Collect all consecutive tool-result messages. + var blocks []map[string]any + for i < len(msgs) && msgs[i].Role == "tool" { + blocks = append(blocks, map[string]any{ + "type": "tool_result", + "tool_use_id": msgs[i].ToolCallID, + "content": msgs[i].Content, + }) + i++ + } + out = append(out, map[string]any{ + "role": "user", + "content": blocks, + }) + + // ── Regular user / assistant text message ───────────────────────────── + default: + out = append(out, map[string]any{ + "role": msg.Role, + "content": msg.Content, + }) + i++ + } + } + + return out +} + +// ── Response parsers ────────────────────────────────────────────────────────── + +// parseMCPResponse extracts the plain-text reply from an Anthropic response. +// Used by CallWithMessages / CallWithRequest (no tool support). func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) { - var response struct { + r, err := c.parseMCPResponseFull(body) + if err != nil { + return "", err + } + return r.Content, nil +} + +// parseMCPResponseFull extracts both text and tool calls from an Anthropic +// response envelope. +// +// Anthropic response shape: +// +// { +// "content": [ +// {"type": "text", "text": "..."}, +// {"type": "tool_use", "id": "...", "name": "...", "input": {...}} +// ], +// "stop_reason": "tool_use" | "end_turn" +// } +func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) { + var raw struct { Content []struct { - Type string `json:"type"` - Text string `json:"text"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` } `json:"content"` Usage struct { InputTokens int `json:"input_tokens"` @@ -109,36 +273,46 @@ func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) { } `json:"error"` } - if err := json.Unmarshal(body, &response); err != nil { - return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body)) + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("failed to parse Anthropic response: %w — body: %s", err, body) + } + if raw.Error != nil { + return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message) } - if response.Error != nil { - return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message) - } - - if len(response.Content) == 0 { - return "", fmt.Errorf("Claude returned empty content, body: %s", string(body)) - } - - // Report token usage if callback is set - totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens - if TokenUsageCallback != nil && totalTokens > 0 { + total := raw.Usage.InputTokens + raw.Usage.OutputTokens + if TokenUsageCallback != nil && total > 0 { TokenUsageCallback(TokenUsage{ Provider: c.Provider, Model: c.Model, - PromptTokens: response.Usage.InputTokens, - CompletionTokens: response.Usage.OutputTokens, - TotalTokens: totalTokens, + PromptTokens: raw.Usage.InputTokens, + CompletionTokens: raw.Usage.OutputTokens, + TotalTokens: total, }) } - // Find text content - for _, content := range response.Content { - if content.Type == "text" { - return content.Text, nil + result := &LLMResponse{} + for _, block := range raw.Content { + switch block.Type { + case "text": + result.Content = block.Text + + case "tool_use": + // Input is a JSON object; serialise back to a JSON string so it + // matches the ToolCallFunction.Arguments field (always a string). + argsJSON, err := json.Marshal(block.Input) + if err != nil { + argsJSON = []byte("{}") + } + result.ToolCalls = append(result.ToolCalls, ToolCall{ + ID: block.ID, + Type: "function", + Function: ToolCallFunction{ + Name: block.Name, + Arguments: string(argsJSON), + }, + }) } } - - return "", fmt.Errorf("no text content in Claude response") + return result, nil } diff --git a/mcp/claude_client_test.go b/mcp/claude_client_test.go new file mode 100644 index 00000000..268c2849 --- /dev/null +++ b/mcp/claude_client_test.go @@ -0,0 +1,248 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" +) + +// ── buildRequestBodyFromRequest ──────────────────────────────────────────────── + +func TestClaudeClient_BuildRequestBody_SystemPromptLifted(t *testing.T) { + c := newTestClaudeClient() + req := &Request{ + Model: "claude-opus-4-6", + Messages: []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + }, + } + body := c.buildRequestBodyFromRequest(req) + + if body["system"] != "You are helpful." { + t.Errorf("system not lifted to top level: %v", body["system"]) + } + msgs := body["messages"].([]map[string]any) + if len(msgs) != 1 || msgs[0]["role"] != "user" { + t.Errorf("system message should be removed from messages array: %v", msgs) + } +} + +func TestClaudeClient_BuildRequestBody_ToolsUseInputSchema(t *testing.T) { + c := newTestClaudeClient() + req := &Request{ + Model: "claude-opus-4-6", + Messages: []Message{{Role: "user", Content: "hi"}}, + Tools: []Tool{{ + Type: "function", + Function: FunctionDef{ + Name: "my_tool", + Description: "does stuff", + Parameters: map[string]any{"type": "object"}, + }, + }}, + } + body := c.buildRequestBodyFromRequest(req) + + tools, ok := body["tools"].([]map[string]any) + if !ok || len(tools) != 1 { + t.Fatalf("tools not set correctly: %v", body["tools"]) + } + tool := tools[0] + if tool["name"] != "my_tool" { + t.Errorf("tool name wrong: %v", tool["name"]) + } + if tool["input_schema"] == nil { + t.Error("tool must use input_schema, not parameters") + } + if _, hasParams := tool["parameters"]; hasParams { + t.Error("tool must NOT have parameters key (Anthropic uses input_schema)") + } +} + +func TestClaudeClient_BuildRequestBody_ToolChoiceObject(t *testing.T) { + c := newTestClaudeClient() + req := &Request{ + Model: "claude-opus-4-6", + Messages: []Message{{Role: "user", Content: "hi"}}, + ToolChoice: "auto", + } + body := c.buildRequestBodyFromRequest(req) + + tc, ok := body["tool_choice"].(map[string]any) + if !ok { + t.Fatalf("tool_choice must be an object, got: %T %v", body["tool_choice"], body["tool_choice"]) + } + if tc["type"] != "auto" { + t.Errorf("tool_choice.type must be 'auto', got: %v", tc["type"]) + } +} + +// ── convertMessagesToAnthropic ───────────────────────────────────────────────── + +func TestConvertMessages_AssistantToolCall(t *testing.T) { + msgs := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "tc1", + Type: "function", + Function: ToolCallFunction{Name: "api_request", Arguments: `{"method":"GET","path":"/api/x","body":{}}`}, + }}, + }, + } + out := convertMessagesToAnthropic(msgs) + + if len(out) != 1 { + t.Fatalf("expected 1 message, got %d", len(out)) + } + msg := out[0] + if msg["role"] != "assistant" { + t.Errorf("role should be assistant: %v", msg["role"]) + } + blocks := msg["content"].([]map[string]any) + if len(blocks) != 1 || blocks[0]["type"] != "tool_use" { + t.Errorf("content should be tool_use block: %v", blocks) + } + if blocks[0]["id"] != "tc1" { + t.Errorf("tool_use id wrong: %v", blocks[0]["id"]) + } + // Input must be parsed JSON object, not a string. + input, ok := blocks[0]["input"].(map[string]any) + if !ok { + t.Errorf("tool_use input must be map, got %T", blocks[0]["input"]) + } + if input["method"] != "GET" { + t.Errorf("input.method wrong: %v", input) + } +} + +func TestConvertMessages_ToolResultMergedIntoUserTurn(t *testing.T) { + // Anthropic requires strictly alternating turns; consecutive tool results + // must be merged into a single user message. + msgs := []Message{ + {Role: "tool", ToolCallID: "tc1", Content: `{"result":"a"}`}, + {Role: "tool", ToolCallID: "tc2", Content: `{"result":"b"}`}, + } + out := convertMessagesToAnthropic(msgs) + + if len(out) != 1 { + t.Fatalf("consecutive tool results must be merged into one user turn, got %d messages", len(out)) + } + if out[0]["role"] != "user" { + t.Errorf("tool results must become role=user: %v", out[0]["role"]) + } + blocks := out[0]["content"].([]map[string]any) + if len(blocks) != 2 { + t.Errorf("expected 2 tool_result blocks, got %d", len(blocks)) + } + if blocks[0]["type"] != "tool_result" || blocks[1]["type"] != "tool_result" { + t.Errorf("blocks should be tool_result: %v", blocks) + } + if blocks[0]["tool_use_id"] != "tc1" || blocks[1]["tool_use_id"] != "tc2" { + t.Errorf("tool_use_id mismatch: %v", blocks) + } +} + +// ── parseMCPResponseFull ─────────────────────────────────────────────────────── + +func TestClaudeClient_ParseResponse_TextOnly(t *testing.T) { + c := newTestClaudeClient() + body := []byte(`{ + "content": [{"type":"text","text":"Hello from Claude"}], + "usage": {"input_tokens": 10, "output_tokens": 5} + }`) + resp, err := c.parseMCPResponseFull(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Content != "Hello from Claude" { + t.Errorf("content mismatch: %q", resp.Content) + } + if len(resp.ToolCalls) != 0 { + t.Errorf("expected no tool calls: %v", resp.ToolCalls) + } +} + +func TestClaudeClient_ParseResponse_ToolUse(t *testing.T) { + c := newTestClaudeClient() + body := []byte(`{ + "content": [{ + "type": "tool_use", + "id": "toolu_01abc", + "name": "api_request", + "input": {"method":"POST","path":"/api/strategies","body":{"name":"BTC策略"}} + }], + "usage": {"input_tokens": 100, "output_tokens": 30} + }`) + resp, err := c.parseMCPResponseFull(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "toolu_01abc" { + t.Errorf("tool call ID wrong: %v", tc.ID) + } + if tc.Function.Name != "api_request" { + t.Errorf("function name wrong: %v", tc.Function.Name) + } + // Arguments must be a valid JSON string. + var args map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + t.Errorf("arguments not valid JSON: %q — %v", tc.Function.Arguments, err) + } + if args["method"] != "POST" { + t.Errorf("args.method wrong: %v", args) + } +} + +func TestClaudeClient_ParseResponse_APIError(t *testing.T) { + c := newTestClaudeClient() + body := []byte(`{"error":{"type":"authentication_error","message":"invalid x-api-key"}}`) + _, err := c.parseMCPResponseFull(body) + if err == nil { + t.Fatal("expected error for API error response") + } + if err.Error() == "" { + t.Error("error message should not be empty") + } +} + +// ── Auth header ──────────────────────────────────────────────────────────────── + +func TestClaudeClient_SetAuthHeader(t *testing.T) { + c := newTestClaudeClient() + c.APIKey = "sk-ant-test123" + + // net/http.Header canonicalizes keys (x-api-key → X-Api-Key). + h := make(http.Header) + c.setAuthHeader(h) + + if got := h.Get("x-api-key"); got != "sk-ant-test123" { + t.Errorf("x-api-key header not set correctly: %q", got) + } + if h.Get("anthropic-version") == "" { + t.Error("anthropic-version header must be set") + } + // Must NOT use Authorization: Bearer (that's OpenAI format). + if h.Get("Authorization") != "" { + t.Error("Claude must use x-api-key, not Authorization header") + } +} + +func TestClaudeClient_BuildUrl(t *testing.T) { + c := newTestClaudeClient() + url := c.buildUrl() + if url != DefaultClaudeBaseURL+"/messages" { + t.Errorf("URL should be /messages endpoint, got: %s", url) + } +} + +// ── helpers ──────────────────────────────────────────────────────────────────── + +func newTestClaudeClient() *ClaudeClient { + return NewClaudeClientWithOptions().(*ClaudeClient) +} diff --git a/mcp/claw402.go b/mcp/claw402.go new file mode 100644 index 00000000..03bb9ca6 --- /dev/null +++ b/mcp/claw402.go @@ -0,0 +1,166 @@ +package mcp + +import ( + "crypto/ecdsa" + "net/http" + "strings" + + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + ProviderClaw402 = "claw402" + DefaultClaw402URL = "https://claw402.ai" + DefaultClaw402Model = "deepseek" +) + +// claw402ModelEndpoints maps user-friendly model names to claw402 API paths. +var claw402ModelEndpoints = map[string]string{ + // OpenAI + "gpt-5.4": "/api/v1/ai/openai/chat/5.4", + "gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro", + "gpt-5.3": "/api/v1/ai/openai/chat/5.3", + "gpt-5-mini": "/api/v1/ai/openai/chat/5-mini", + // Anthropic + "claude-opus": "/api/v1/ai/anthropic/messages/opus", + // DeepSeek + "deepseek": "/api/v1/ai/deepseek/chat", + "deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner", + // Qwen + "qwen-max": "/api/v1/ai/qwen/chat/max", + "qwen-plus": "/api/v1/ai/qwen/chat/plus", + "qwen-turbo": "/api/v1/ai/qwen/chat/turbo", + "qwen-flash": "/api/v1/ai/qwen/chat/flash", + // Grok + "grok-4.1": "/api/v1/ai/grok/chat/4.1", + // Gemini + "gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro", + // Kimi + "kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5", +} + +// Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway. +// Reuses the same EIP-712 signing as BlockRunBaseClient (same Base chain + USDC contract). +// When the selected model routes to an Anthropic endpoint, it automatically uses +// the Anthropic wire format for requests and responses (via an internal ClaudeClient). +type Claw402Client struct { + *Client + privateKey *ecdsa.PrivateKey + claudeProxy *ClaudeClient // non-nil when endpoint is /anthropic/ +} + +// NewClaw402Client creates a claw402 client (backward compatible). +func NewClaw402Client() AIClient { + return NewClaw402ClientWithOptions() +} + +// NewClaw402ClientWithOptions creates a claw402 client with options. +func NewClaw402ClientWithOptions(opts ...ClientOption) AIClient { + baseOpts := []ClientOption{ + WithProvider(ProviderClaw402), + WithModel(DefaultClaw402Model), + WithBaseURL(DefaultClaw402URL), + } + allOpts := append(baseOpts, opts...) + baseClient := NewClient(allOpts...).(*Client) + baseClient.UseFullURL = true + baseClient.BaseURL = DefaultClaw402URL + claw402ModelEndpoints[DefaultClaw402Model] + + c := &Claw402Client{Client: baseClient} + baseClient.hooks = c + return c +} + +// SetAPIKey stores the EVM private key and selects the model endpoint. +func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) { + hexKey := strings.TrimPrefix(apiKey, "0x") + privKey, err := crypto.HexToECDSA(hexKey) + if err != nil { + c.logger.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err) + } else { + c.privateKey = privKey + c.APIKey = apiKey + addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex() + c.logger.Infof("🔧 [MCP] Claw402 wallet: %s", addr) + } + if customModel != "" { + c.Model = customModel + } + endpoint := c.resolveEndpoint() + c.BaseURL = DefaultClaw402URL + endpoint + + // Anthropic endpoints need different wire format (Messages API) + if strings.Contains(endpoint, "/anthropic/") { + c.claudeProxy = &ClaudeClient{Client: c.Client} + c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint) + } else { + c.claudeProxy = nil + c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint) + } +} + +// resolveEndpoint returns the API path for the configured model. +func (c *Claw402Client) resolveEndpoint() string { + if ep, ok := claw402ModelEndpoints[c.Model]; ok { + return ep + } + // Allow raw path override (e.g. "/api/v1/ai/openai/chat/5.4") + if strings.HasPrefix(c.Model, "/api/") { + return c.Model + } + return claw402ModelEndpoints[DefaultClaw402Model] +} + +func (c *Claw402Client) setAuthHeader(h http.Header) { x402SetAuthHeader(h) } + +func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) { + return x402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt) +} + +func (c *Claw402Client) CallWithRequestFull(req *Request) (*LLMResponse, error) { + return x402CallFull(c.Client, c.signPayment, "Claw402", req) +} + +// signPayment signs x402 v2 EIP-712 payment (same Base chain + USDC as BlockRunBase). +func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) { + return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402") +} + +// ── Format overrides for Anthropic endpoints ───────────────────────────────── + +func (c *Claw402Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { + if c.claudeProxy != nil { + return c.claudeProxy.buildMCPRequestBody(systemPrompt, userPrompt) + } + return c.Client.buildMCPRequestBody(systemPrompt, userPrompt) +} + +func (c *Claw402Client) buildRequestBodyFromRequest(req *Request) map[string]any { + if c.claudeProxy != nil { + return c.claudeProxy.buildRequestBodyFromRequest(req) + } + return c.Client.buildRequestBodyFromRequest(req) +} + +func (c *Claw402Client) parseMCPResponse(body []byte) (string, error) { + if c.claudeProxy != nil { + return c.claudeProxy.parseMCPResponse(body) + } + return c.Client.parseMCPResponse(body) +} + +func (c *Claw402Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) { + if c.claudeProxy != nil { + return c.claudeProxy.parseMCPResponseFull(body) + } + return c.Client.parseMCPResponseFull(body) +} + +// buildUrl returns the full claw402 endpoint URL. +func (c *Claw402Client) buildUrl() string { + return c.BaseURL +} + +func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) { + return x402BuildRequest(url, jsonData) +} diff --git a/mcp/client.go b/mcp/client.go index 3e778fb1..5b914965 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1,7 +1,9 @@ package mcp import ( + "bufio" "bytes" + "context" "encoding/json" "fmt" "io" @@ -232,10 +234,21 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er } func (client *Client) parseMCPResponse(body []byte) (string, error) { + r, err := client.parseMCPResponseFull(body) + if err != nil { + return "", err + } + return r.Content, nil +} + +// parseMCPResponseFull parses the OpenAI-format response body and returns both +// the text content and any tool calls. +func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) { var result struct { Choices []struct { Message struct { - Content string `json:"content"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls"` } `json:"message"` } `json:"choices"` Usage struct { @@ -246,11 +259,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) { } if err := json.Unmarshal(body, &result); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) + return nil, fmt.Errorf("failed to parse response: %w", err) } if len(result.Choices) == 0 { - return "", fmt.Errorf("API returned empty response") + return nil, fmt.Errorf("API returned empty response") } // Report token usage if callback is set @@ -264,7 +277,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) { }) } - return result.Choices[0].Message.Content, nil + msg := result.Choices[0].Message + return &LLMResponse{ + Content: msg.Content, + ToolCalls: msg.ToolCalls, + }, nil } func (client *Client) buildUrl() string { @@ -425,50 +442,106 @@ func (client *Client) CallWithRequest(req *Request) (string, error) { return "", fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr) } +// CallWithRequestFull calls the AI API and returns both text content and tool calls. +func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) { + if client.APIKey == "" { + return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first") + } + if req.Model == "" { + req.Model = client.Model + } + + var lastErr error + maxRetries := client.config.MaxRetries + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries) + } + result, err := client.callWithRequestFull(req) + if err == nil { + return result, nil + } + lastErr = err + if !client.hooks.isRetryableError(err) { + return nil, err + } + if attempt < maxRetries { + waitTime := client.config.RetryWaitBase * time.Duration(attempt) + time.Sleep(waitTime) + } + } + return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr) +} + +// callWithRequestFull single call that returns LLMResponse (content + tool calls). +func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) { + client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL) + + requestBody := client.hooks.buildRequestBodyFromRequest(req) + jsonData, err := client.hooks.marshalRequestBody(requestBody) + if err != nil { + return nil, err + } + + url := client.hooks.buildUrl() + httpReq, err := client.hooks.buildRequest(url, jsonData) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body)) + } + + return client.hooks.parseMCPResponseFull(body) +} + // callWithRequest single AI API call (using Request object) func (client *Client) callWithRequest(req *Request) (string, error) { // Print current AI configuration client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL) client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages)) - // Build request body (from Request object) - requestBody := client.buildRequestBodyFromRequest(req) + requestBody := client.hooks.buildRequestBodyFromRequest(req) - // Serialize request body jsonData, err := client.hooks.marshalRequestBody(requestBody) if err != nil { return "", err } - // Build URL url := client.hooks.buildUrl() client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url) - // Create HTTP request httpReq, err := client.hooks.buildRequest(url, jsonData) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - // Send HTTP request resp, err := client.httpClient.Do(httpReq) if err != nil { return "", fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) } - // Check HTTP status code if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body)) } - // Parse response result, err := client.hooks.parseMCPResponse(body) if err != nil { return "", fmt.Errorf("fail to parse AI server response: %w", err) @@ -479,13 +552,23 @@ func (client *Client) callWithRequest(req *Request) (string, error) { // buildRequestBodyFromRequest builds request body from Request object func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any { - // Convert Message to API format - messages := make([]map[string]string, 0, len(req.Messages)) + // Convert Message to API format — must use map[string]any to support + // tool-call messages (tool_calls, tool_call_id fields). + messages := make([]map[string]any, 0, len(req.Messages)) for _, msg := range req.Messages { - messages = append(messages, map[string]string{ - "role": msg.Role, - "content": msg.Content, - }) + m := map[string]any{"role": msg.Role} + if len(msg.ToolCalls) > 0 { + // Assistant message that contains tool invocations. + // content must be null/omitted for OpenAI compatibility. + m["tool_calls"] = msg.ToolCalls + } else if msg.ToolCallID != "" { + // Tool result message (role="tool"). + m["tool_call_id"] = msg.ToolCallID + m["content"] = msg.Content + } else { + m["content"] = msg.Content + } + messages = append(messages, m) } // Build basic request body @@ -544,3 +627,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.hooks.buildRequestBodyFromRequest(req) + jsonData, err := client.hooks.marshalRequestBody(requestBody) + if err != nil { + return "", err + } + + url := client.hooks.buildUrl() + httpReq, err := client.hooks.buildRequest(url, jsonData) + if err != nil { + return "", err + } + + // Idle-timeout watchdog: cancel the request if no SSE line arrives for 30 seconds. + // This breaks the scanner out of an indefinitely blocking Read on a hung connection. + const idleTimeout = 60 * time.Second + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + resetCh := make(chan struct{}, 1) + go func() { + t := time.NewTimer(idleTimeout) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + cancel() // idle timeout: kill the connection + return + case <-resetCh: + // received a line — reset the idle timer + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(idleTimeout) + } + } + }() + + httpReq = httpReq.WithContext(ctx) + resp, err := client.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("streaming request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var accumulated strings.Builder + scanner := bufio.NewScanner(resp.Body) + + for scanner.Scan() { + // Ping the watchdog: we received a line, reset the idle timer. + select { + case resetCh <- struct{}{}: + default: + } + + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + // Parse the SSE JSON chunk + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue // skip malformed chunks + } + if len(chunk.Choices) == 0 { + continue + } + + delta := chunk.Choices[0].Delta.Content + if delta == "" { + continue + } + + accumulated.WriteString(delta) + if onChunk != nil { + onChunk(accumulated.String()) + } + } + + if err := scanner.Err(); err != nil { + return accumulated.String(), fmt.Errorf("stream interrupted: %w", err) + } + + return accumulated.String(), nil +} diff --git a/mcp/interface.go b/mcp/interface.go index 696b03ba..c7e62edc 100644 --- a/mcp/interface.go +++ b/mcp/interface.go @@ -10,21 +10,52 @@ 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) + // CallWithRequestFull returns both text content and tool calls. + // Use this when the request includes Tools — the LLM may respond with + // either a plain text reply (LLMResponse.Content) or tool invocations + // (LLMResponse.ToolCalls), but not both. + CallWithRequestFull(req *Request) (*LLMResponse, error) } -// clientHooks internal hook interface (for subclass to override specific steps) -// These methods are only used inside the package to implement dynamic dispatch +// clientHooks is the internal dispatch interface used to implement per-provider +// polymorphism without Go's lack of virtual methods. +// +// Each method can be overridden by an embedding struct (e.g. ClaudeClient). +// The base *Client provides OpenAI-compatible defaults; providers with a +// different wire format (Anthropic, Gemini native, etc.) override only what +// differs. All call-path methods in client.go invoke these via c.hooks so +// that the override is always picked up at runtime. type clientHooks interface { - // Hook methods that can be overridden by subclass - + // ── Simple CallWithMessages path ──────────────────────────────────────── call(systemPrompt, userPrompt string) (string, error) - buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any + + // ── Shared request plumbing ───────────────────────────────────────────── buildUrl() string buildRequest(url string, jsonData []byte) (*http.Request, error) setAuthHeader(reqHeaders http.Header) marshalRequestBody(requestBody map[string]any) ([]byte, error) + + // ── Advanced (Request-object) path ────────────────────────────────────── + // buildRequestBodyFromRequest converts a *Request into the provider's + // native wire-format map. Providers that use a different protocol (e.g. + // Anthropic uses "input_schema" for tools, "tool_use" content blocks, and + // a top-level "system" field) override this method. + buildRequestBodyFromRequest(req *Request) map[string]any + + // parseMCPResponse extracts the plain-text reply from a non-streaming + // response body. parseMCPResponse(body []byte) (string, error) + + // parseMCPResponseFull extracts both text and tool calls. Providers whose + // response envelope differs from the OpenAI choices[] structure (e.g. + // Anthropic content[] with tool_use blocks) override this method. + parseMCPResponseFull(body []byte) (*LLMResponse, error) + isRetryableError(err error) bool } diff --git a/mcp/request.go b/mcp/request.go index 3ade2d71..548ef094 100644 --- a/mcp/request.go +++ b/mcp/request.go @@ -1,9 +1,34 @@ package mcp -// Message represents a conversation message +// Message represents a conversation message. +// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls), +// and tool result messages (Role="tool", ToolCallID, Content). type Message struct { - Role string `json:"role"` // "system", "user", "assistant" - Content string `json:"content"` // Message content + Role string `json:"role"` // "system", "user", "assistant", "tool" + Content string `json:"content,omitempty"` // Text content (omitted when ToolCalls present) + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Set by assistant when calling tools + ToolCallID string `json:"tool_call_id,omitempty"` // Set on role="tool" result messages +} + +// ToolCall is a single function call requested by the LLM. +type ToolCall struct { + ID string `json:"id"` // Unique call ID (e.g. "call_abc123") + Type string `json:"type"` // Always "function" + Function ToolCallFunction `json:"function"` // Function name and JSON-serialised arguments +} + +// ToolCallFunction holds the function name and raw JSON arguments string. +type ToolCallFunction struct { + Name string `json:"name"` // Function name + Arguments string `json:"arguments"` // JSON-encoded argument object +} + +// LLMResponse is returned by CallWithRequestFull and carries both the assistant +// text reply (Content) and any structured tool calls (ToolCalls). +// Exactly one of the two fields will be non-empty for a well-formed response. +type LLMResponse struct { + Content string // Plain-text reply (final answer) + ToolCalls []ToolCall // Structured tool invocations } // Tool represents a tool/function that AI can call diff --git a/mcp/x402.go b/mcp/x402.go new file mode 100644 index 00000000..5debb8d1 --- /dev/null +++ b/mcp/x402.go @@ -0,0 +1,219 @@ +package mcp + +import ( + "bytes" + "crypto/ecdsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/ethereum/go-ethereum/crypto" +) + +// ── Shared x402 types ──────────────────────────────────────────────────────── + +// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2). +type x402v2PaymentRequired struct { + X402Version int `json:"x402Version"` + Accepts []x402AcceptOption `json:"accepts"` + Resource *x402Resource `json:"resource"` +} + +// x402AcceptOption is a payment option from the x402 v2 header. +type x402AcceptOption struct { + Scheme string `json:"scheme"` + Network string `json:"network"` + Amount string `json:"amount"` + Asset string `json:"asset"` + PayTo string `json:"payTo"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` + Extra map[string]string `json:"extra"` +} + +// x402Resource describes the resource being paid for. +type x402Resource struct { + URL string `json:"url"` + Description string `json:"description"` + MimeType string `json:"mimeType"` +} + +// x402SignFunc is a callback that signs an x402 payment header and returns the +// base64-encoded payment signature. +type x402SignFunc func(paymentHeaderB64 string) (string, error) + +// ── Shared x402 helpers ────────────────────────────────────────────────────── + +// x402DecodeHeader decodes a base64-encoded x402 Payment-Required header, +// trying RawStdEncoding first then StdEncoding as fallback. +func x402DecodeHeader(b64 string) ([]byte, error) { + decoded, err := base64.RawStdEncoding.DecodeString(b64) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("failed to base64-decode payment header: %w", err) + } + } + return decoded, nil +} + +// signBasePaymentHeader decodes a base64 x402 header, parses it, and signs with +// EIP-712 (USDC TransferWithAuthorization). Shared by BlockRunBase and Claw402. +func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) { + if privateKey == nil { + return "", fmt.Errorf("no private key set for %s wallet", providerName) + } + + decoded, err := x402DecodeHeader(paymentHeaderB64) + if err != nil { + return "", err + } + + var req x402v2PaymentRequired + if err := json.Unmarshal(decoded, &req); err != nil { + return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err) + } + if len(req.Accepts) == 0 { + return "", fmt.Errorf("no payment options in x402 response") + } + + senderAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + return signX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource) +} + +// doX402Request executes an HTTP request and handles the x402 v2 payment flow. +// On a 402 response it reads the Payment-Required (or X-Payment-Required) header, +// signs via signFn, retries with Payment-Signature, and logs the Payment-Response +// header (tx hash) on success. +func doX402Request( + httpClient *http.Client, + buildReqFn func() (*http.Request, error), + signFn x402SignFunc, + providerTag string, + logger Logger, +) ([]byte, error) { + req, err := buildReqFn() + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPaymentRequired { + paymentHeader := resp.Header.Get("Payment-Required") + if paymentHeader == "" { + paymentHeader = resp.Header.Get("X-Payment-Required") + } + if paymentHeader == "" { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("received 402 but no Payment-Required header found. Body: %s", string(body)) + } + + // Drain 402 body to allow HTTP connection reuse. + _, _ = io.Copy(io.Discard, resp.Body) + + paymentSig, err := signFn(paymentHeader) + if err != nil { + return nil, fmt.Errorf("failed to sign x402 payment: %w", err) + } + + req2, err := buildReqFn() + if err != nil { + return nil, fmt.Errorf("failed to build retry request: %w", err) + } + req2.Header.Set("X-Payment", paymentSig) + req2.Header.Set("Payment-Signature", paymentSig) + + resp2, err := httpClient.Do(req2) + if err != nil { + return nil, fmt.Errorf("failed to send payment retry: %w", err) + } + defer resp2.Body.Close() + + body2, err := io.ReadAll(resp2.Body) + if err != nil { + return nil, fmt.Errorf("failed to read payment retry response: %w", err) + } + if resp2.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2)) + } + + if txHash := resp2.Header.Get("Payment-Response"); txHash != "" { + logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash) + } + + return body2, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body)) + } + return body, nil +} + +// x402BuildRequest creates a POST request with Content-Type but no auth header. +func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) { + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("fail to build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +// x402SetAuthHeader is a no-op — x402 providers authenticate via payment signing. +func x402SetAuthHeader(_ http.Header) {} + +// x402Call handles the x402 payment flow for the simple CallWithMessages path. +func x402Call(c *Client, signFn x402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) { + c.logger.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL) + + requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt) + jsonData, err := c.hooks.marshalRequestBody(requestBody) + if err != nil { + return "", err + } + + body, err := doX402Request(c.httpClient, func() (*http.Request, error) { + return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData) + }, signFn, tag, c.logger) + if err != nil { + return "", err + } + return c.hooks.parseMCPResponse(body) +} + +// x402CallFull handles the x402 payment flow for the advanced Request path. +func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LLMResponse, error) { + if c.APIKey == "" { + return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first") + } + if req.Model == "" { + req.Model = c.Model + } + + c.logger.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL) + + requestBody := c.hooks.buildRequestBodyFromRequest(req) + jsonData, err := c.hooks.marshalRequestBody(requestBody) + if err != nil { + return nil, err + } + + body, err := doX402Request(c.httpClient, func() (*http.Request, error) { + return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData) + }, signFn, tag, c.logger) + if err != nil { + return nil, err + } + return c.hooks.parseMCPResponseFull(body) +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index e09eec2c..85192be6 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -15,11 +15,18 @@ server { gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; + # index.html — never cache (so new deploys take effect immediately) + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires 0; + } + # Frontend routes (SPA) with static asset caching location / { try_files $uri $uri/ /index.html; - # Cache static assets + # Cache hashed static assets (js/css have content hashes in filenames) location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; diff --git a/start.sh b/start.sh index 8ce534b3..c0c4232e 100755 --- a/start.sh +++ b/start.sh @@ -1,7 +1,7 @@ #!/bin/bash # ═══════════════════════════════════════════════════════════════ -# NOFX AI Trading System - Docker Quick Start Script +# NOFX AI Trading System - Docker Management Script # Usage: ./start.sh [command] # ═══════════════════════════════════════════════════════════════ @@ -45,10 +45,10 @@ detect_compose_cmd() { elif command -v docker-compose &> /dev/null; then COMPOSE_CMD="docker-compose" else - print_error "Docker Compose 未安装!请先安装 Docker Compose" + print_error "Docker Compose not found. Please install Docker Compose first." exit 1 fi - print_info "使用 Docker Compose 命令: $COMPOSE_CMD" + print_info "Using Docker Compose: $COMPOSE_CMD" } # ------------------------------------------------------------------------ @@ -56,12 +56,12 @@ detect_compose_cmd() { # ------------------------------------------------------------------------ check_docker() { if ! command -v docker &> /dev/null; then - print_error "Docker 未安装!请先安装 Docker: https://docs.docker.com/get-docker/" + print_error "Docker not found. Please install Docker: https://docs.docker.com/get-docker/" exit 1 fi detect_compose_cmd - print_success "Docker 和 Docker Compose 已安装" + print_success "Docker and Docker Compose are installed" } # ------------------------------------------------------------------------ @@ -69,11 +69,11 @@ check_docker() { # ------------------------------------------------------------------------ check_env() { if [ ! -f ".env" ]; then - print_warning ".env 不存在,从模板复制..." + print_warning ".env not found, copying from template..." cp .env.example .env - print_info "已创建 .env 文件" + print_info ".env file created" fi - print_success "环境变量文件存在" + print_success "Environment file exists" } # ------------------------------------------------------------------------ @@ -83,15 +83,15 @@ is_env_configured() { local var_name="$1" local value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2-) - # 去除引号 + # Strip quotes value=$(echo "$value" | tr -d '"'"'") - # 检查是否为空或占位符 + # Check empty if [ -z "$value" ]; then return 1 fi - # 检查是否是示例值 + # Check placeholder values case "$value" in *your-*|*YOUR_*|*change-this*|*CHANGE_THIS*|*example*|*EXAMPLE*) return 1 @@ -102,22 +102,23 @@ is_env_configured() { } # ------------------------------------------------------------------------ -# Helper: Generate and set env var in .env file +# Helper: Set env var in .env file # ------------------------------------------------------------------------ set_env_var() { local var_name="$1" local var_value="$2" - # 如果变量已存在(即使是占位符),替换它 if grep -q "^${var_name}=" .env 2>/dev/null; then - # macOS 和 Linux 兼容的 sed if [[ "$OSTYPE" == "darwin"* ]]; then sed -i '' "s|^${var_name}=.*|${var_name}=${var_value}|" .env else sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" .env fi else - # 变量不存在,追加 + # Ensure .env ends with a newline before appending + if [ -s ".env" ] && [ "$(tail -c1 .env | wc -l)" -eq 0 ]; then + echo "" >> .env + fi echo "${var_name}=${var_value}" >> .env fi } @@ -126,51 +127,46 @@ set_env_var() { # Validation: Encryption Keys in .env # ------------------------------------------------------------------------ check_encryption() { - print_info "检查加密密钥配置..." + print_info "Checking encryption keys..." local generated=false - # 检查并生成 JWT_SECRET if ! is_env_configured "JWT_SECRET"; then - print_warning "JWT_SECRET 未配置,正在生成..." + print_warning "JWT_SECRET not set, generating..." local jwt_secret=$(openssl rand -base64 32) set_env_var "JWT_SECRET" "$jwt_secret" - print_success "JWT_SECRET 已生成" + print_success "JWT_SECRET generated" generated=true fi - # 检查并生成 DATA_ENCRYPTION_KEY if ! is_env_configured "DATA_ENCRYPTION_KEY"; then - print_warning "DATA_ENCRYPTION_KEY 未配置,正在生成..." + print_warning "DATA_ENCRYPTION_KEY not set, generating..." local data_key=$(openssl rand -base64 32) set_env_var "DATA_ENCRYPTION_KEY" "$data_key" - print_success "DATA_ENCRYPTION_KEY 已生成" + print_success "DATA_ENCRYPTION_KEY generated" generated=true fi - # 检查并生成 RSA_PRIVATE_KEY if ! is_env_configured "RSA_PRIVATE_KEY"; then - print_warning "RSA_PRIVATE_KEY 未配置,正在生成..." - # 生成 RSA 密钥并转换为单行格式(\n 替换为 \\n) + print_warning "RSA_PRIVATE_KEY not set, generating..." local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}') set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\"" - print_success "RSA_PRIVATE_KEY 已生成" + print_success "RSA_PRIVATE_KEY generated" generated=true fi if [ "$generated" = true ]; then echo "" - print_success "所有缺失的密钥已自动生成并保存到 .env" - print_warning "请妥善保管 .env 文件,不要提交到版本控制系统" + print_success "Missing keys generated and saved to .env" + print_warning "Keep .env safe — do not commit it to version control" echo "" fi - print_success "加密密钥检查完成" + print_success "Encryption keys OK" print_info " • JWT_SECRET: OK" print_info " • DATA_ENCRYPTION_KEY: OK" print_info " • RSA_PRIVATE_KEY: OK" - # 修复 .env 文件权限 chmod 600 .env 2>/dev/null || true } @@ -197,13 +193,12 @@ read_env_vars() { # Validation: Database Directory (data/) # ------------------------------------------------------------------------ check_database() { - # Ensure data directory exists if [ ! -d "data" ]; then - print_warning "数据目录不存在,创建 data/ 目录..." + print_warning "Data directory missing, creating data/..." install -m 700 -d data - print_success "已创建 data/ 目录" + print_success "data/ directory created" else - print_success "数据目录存在" + print_success "Data directory exists" fi } @@ -211,47 +206,58 @@ check_database() { # Service Management: Start # ------------------------------------------------------------------------ start() { - print_info "正在启动 NOFX AI Trading System..." + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN}║ 🚀 NOFX AI Trading Bot — Startup ║${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════╝${NC}" + echo "" read_env_vars if [ ! -d "data" ]; then - print_info "创建数据目录..." install -m 700 -d data fi + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + print_info "Starting services..." + if [ "$1" == "--build" ]; then - print_info "重新构建镜像..." $COMPOSE_CMD up -d --build else - print_info "启动容器..." $COMPOSE_CMD up -d fi - print_success "服务已启动!" - print_info "Web 界面: http://localhost:${NOFX_FRONTEND_PORT}" - print_info "API 端点: http://localhost:${NOFX_BACKEND_PORT}" - print_info "" - print_info "查看日志: ./start.sh logs" - print_info "停止服务: ./start.sh stop" + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Started! Next steps: ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + echo "" + echo " 1. Open the web dashboard to register and configure" + echo " 2. Add an AI model and exchange in Settings" + echo " 3. (Optional) Add a Telegram bot token in Settings → Telegram" + echo "" + echo -e " Web dashboard: ${BLUE}http://localhost:${NOFX_FRONTEND_PORT}${NC}" + echo -e " View logs: ${YELLOW}./start.sh logs${NC}" + echo -e " Stop: ${YELLOW}./start.sh stop${NC}" + echo "" } # ------------------------------------------------------------------------ # Service Management: Stop # ------------------------------------------------------------------------ stop() { - print_info "正在停止服务..." + print_info "Stopping services..." $COMPOSE_CMD stop - print_success "服务已停止" + print_success "Services stopped" } # ------------------------------------------------------------------------ # Service Management: Restart # ------------------------------------------------------------------------ restart() { - print_info "正在重启服务..." + print_info "Restarting services..." $COMPOSE_CMD restart - print_success "服务已重启" + print_success "Services restarted" } # ------------------------------------------------------------------------ @@ -271,25 +277,25 @@ logs() { status() { read_env_vars - print_info "服务状态:" + print_info "Service status:" $COMPOSE_CMD ps echo "" - print_info "健康检查:" - curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "后端未响应" + print_info "Health check:" + curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "Backend not responding" } # ------------------------------------------------------------------------ # Maintenance: Clean (Destructive) # ------------------------------------------------------------------------ clean() { - print_warning "这将删除所有容器和数据!" - read -p "确认删除?(yes/no): " confirm + print_warning "This will delete all containers and data!" + read -p "Confirm? (yes/no): " confirm if [ "$confirm" == "yes" ]; then - print_info "正在清理..." + print_info "Cleaning up..." $COMPOSE_CMD down -v - print_success "清理完成" + print_success "Cleanup complete" else - print_info "已取消" + print_info "Cancelled" fi } @@ -297,77 +303,74 @@ clean() { # Maintenance: Update # ------------------------------------------------------------------------ update() { - print_info "正在更新..." + print_info "Updating..." git pull $COMPOSE_CMD up -d --build - print_success "更新完成" + print_success "Update complete" } # ------------------------------------------------------------------------ # Command: Regenerate all keys (force) # ------------------------------------------------------------------------ regenerate_keys() { - print_warning "这将重新生成所有加密密钥!" - print_warning "如果已有加密数据,重新生成后将无法解密!" + print_warning "This will regenerate ALL encryption keys!" + print_warning "Any existing encrypted data will become unreadable!" echo "" - read -p "确认重新生成?(yes/no): " confirm + read -p "Confirm? (yes/no): " confirm if [ "$confirm" != "yes" ]; then - print_info "已取消" + print_info "Cancelled" return fi check_env - print_info "正在生成新的密钥..." + print_info "Generating new keys..." - # 生成 JWT_SECRET local jwt_secret=$(openssl rand -base64 32) set_env_var "JWT_SECRET" "$jwt_secret" - print_success "JWT_SECRET 已生成" + print_success "JWT_SECRET generated" - # 生成 DATA_ENCRYPTION_KEY local data_key=$(openssl rand -base64 32) set_env_var "DATA_ENCRYPTION_KEY" "$data_key" - print_success "DATA_ENCRYPTION_KEY 已生成" + print_success "DATA_ENCRYPTION_KEY generated" - # 生成 RSA_PRIVATE_KEY local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}') set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\"" - print_success "RSA_PRIVATE_KEY 已生成" + print_success "RSA_PRIVATE_KEY generated" chmod 600 .env 2>/dev/null || true echo "" - print_success "所有密钥已重新生成并保存到 .env" - print_warning "请妥善保管 .env 文件" + print_success "All keys regenerated and saved to .env" + print_warning "Keep .env safe" } # ------------------------------------------------------------------------ # Help: Usage Information # ------------------------------------------------------------------------ show_help() { - echo "NOFX AI Trading System - Docker 管理脚本" + echo "NOFX AI Trading System - Docker Management Script" echo "" - echo "用法: ./start.sh [command] [options]" + echo "Usage: ./start.sh [command] [options]" echo "" - echo "命令:" - echo " start [--build] 启动服务(可选:重新构建)" - echo " stop 停止服务" - echo " restart 重启服务" - echo " logs [service] 查看日志(可选:指定服务名 backend/frontend)" - echo " status 查看服务状态" - echo " clean 清理所有容器和数据" - echo " update 更新代码并重启" - echo " regenerate-keys 重新生成所有加密密钥(慎用)" - echo " help 显示此帮助信息" + echo "Commands:" + echo " start [--build] Start services (optional: rebuild images)" + echo " stop Stop services" + echo " restart Restart services" + echo " logs [service] View logs (optional: backend / frontend)" + echo " status Show service status" + echo " clean Remove all containers and data" + echo " update Pull latest code and rebuild" + echo " regenerate-keys Regenerate all encryption keys (destructive)" + echo " help Show this help" echo "" - echo "示例:" - echo " ./start.sh start --build # 构建并启动" - echo " ./start.sh logs backend # 查看后端日志" - echo " ./start.sh status # 查看状态" + echo "Examples:" + echo " ./start.sh start --build # Build and start" + echo " ./start.sh logs backend # View backend logs" + echo " ./start.sh status # Check status" echo "" - echo "首次使用:" - echo " 直接运行 ./start.sh 即可,缺失的密钥会自动生成" + echo "First time:" + echo " Just run ./start.sh — missing keys are generated automatically" } # ------------------------------------------------------------------------ @@ -408,7 +411,7 @@ main() { show_help ;; *) - print_error "未知命令: $1" + print_error "Unknown command: $1" show_help exit 1 ;; diff --git a/store/ai_model.go b/store/ai_model.go index a9047866..b74d5780 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -137,6 +137,19 @@ func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) { return &model, nil } +// GetAnyEnabled returns the first enabled AI model across all users. +// Used by single-user features (e.g. Telegram bot) that need any working LLM client. +func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) { + var model AIModel + err := s.db.Where("enabled = ? AND api_key != ''", true). + Order("updated_at DESC, id ASC"). + First(&model).Error + if err != nil { + return nil, err + } + return &model, nil +} + // Update updates AI model, creates if not exists // IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten) func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error { diff --git a/store/store.go b/store/store.go index 8119b935..5e6ec457 100644 --- a/store/store.go +++ b/store/store.go @@ -18,17 +18,18 @@ type Store struct { driver *DBDriver // Database driver for abstraction (legacy) // Sub-stores (lazy initialization) - user *UserStore - aiModel *AIModelStore - exchange *ExchangeStore - trader *TraderStore - decision *DecisionStore - backtest *BacktestStore - position *PositionStore - strategy *StrategyStore - equity *EquityStore - order *OrderStore - grid *GridStore + user *UserStore + aiModel *AIModelStore + exchange *ExchangeStore + trader *TraderStore + decision *DecisionStore + backtest *BacktestStore + position *PositionStore + strategy *StrategyStore + equity *EquityStore + order *OrderStore + grid *GridStore + telegramConfig TelegramConfigStore mu sync.RWMutex } @@ -160,6 +161,9 @@ func (s *Store) initTables() error { if err := s.Grid().InitTables(); err != nil { return fmt.Errorf("failed to initialize grid tables: %w", err) } + if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil { + return fmt.Errorf("failed to initialize telegram config tables: %w", err) + } return nil } @@ -293,6 +297,16 @@ func (s *Store) Grid() *GridStore { return s.grid } +// TelegramConfig gets Telegram bot configuration storage +func (s *Store) TelegramConfig() TelegramConfigStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.telegramConfig == nil { + s.telegramConfig = NewTelegramConfigStore(s.gdb) + } + return s.telegramConfig +} + // Close closes database connection func (s *Store) Close() error { if s.driver != nil { diff --git a/store/telegram_config.go b/store/telegram_config.go new file mode 100644 index 00000000..2c15f15a --- /dev/null +++ b/store/telegram_config.go @@ -0,0 +1,164 @@ +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 + Language string `gorm:"column:language;default:''"` // "zh" or "en"; empty = not chosen yet + CreatedAt time.Time + UpdatedAt time.Time +} + +// String returns a safe string representation of TelegramConfig with the token masked. +func (tc TelegramConfig) String() string { + token := "***" + if tc.BotToken == "" { + token = "" + } + return fmt.Sprintf("TelegramConfig{ID:%d, ChatID:%d, Username:%q, BotToken:%s, BoundAt:%v}", + tc.ID, tc.ChatID, tc.Username, token, tc.BoundAt) +} + +// TelegramConfigStore defines the interface for Telegram bot binding operations +type TelegramConfigStore interface { + Get() (*TelegramConfig, error) // Get current config (may not exist) + SaveToken(botToken string) error // Save bot token only (Web UI sets this) + Save(botToken, modelID string) error // Save bot token + selected AI model + BindUser(chatID int64, username string) error // Called on first /start + IsBound() (bool, error) // Check if any user is bound + GetBoundChatID() (int64, error) // Get bound chat ID (0 if not bound) + Unbind() error // Remove binding + SetLanguage(lang string) error // Set UI language ("en" or "zh") + GetLanguage() string // Get UI language; returns "en" if not set +} + +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 +} + +func (s *telegramConfigStore) SetLanguage(lang 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.Language = lang + return s.db.Save(&cfg).Error +} + +func (s *telegramConfigStore) GetLanguage() string { + s.mu.RLock() + defer s.mu.RUnlock() + var cfg TelegramConfig + if err := s.db.First(&cfg, 1).Error; err != nil { + return "en" // default: English + } + if cfg.Language == "" { + return "en" + } + return cfg.Language +} diff --git a/store/user.go b/store/user.go index 73c9a671..5b084683 100644 --- a/store/user.go +++ b/store/user.go @@ -97,6 +97,13 @@ func (s *UserStore) GetAllIDs() ([]string, error) { return userIDs, err } +// GetAll returns all users ordered by creation time. +func (s *UserStore) GetAll() ([]User, error) { + var users []User + err := s.db.Model(&User{}).Order("created_at").Find(&users).Error + return users, err +} + // UpdatePassword updates password func (s *UserStore) UpdatePassword(userID, passwordHash string) error { return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ diff --git a/telegram/agent/agent.go b/telegram/agent/agent.go new file mode 100644 index 00000000..8c4455d2 --- /dev/null +++ b/telegram/agent/agent.go @@ -0,0 +1,285 @@ +package agent + +import ( + "encoding/json" + "fmt" + "nofx/auth" + "nofx/logger" + "nofx/mcp" + "nofx/telegram/session" + "strings" +) + +const maxIterations = 10 + +// apiRequestTool is the single tool exposed to the LLM. +// Native function calling means the LLM returns EITHER ToolCalls OR Content — never both. +// This makes narration structurally impossible: text cannot appear alongside a tool call. +var apiRequestTool = mcp.Tool{ + Type: "function", + Function: mcp.FunctionDef{ + Name: "api_request", + Description: "Call the NOFX trading system REST API", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "method": map[string]any{ + "type": "string", + "enum": []string{"GET", "POST", "PUT", "DELETE"}, + "description": "HTTP method", + }, + "path": map[string]any{ + "type": "string", + "description": "API path; include query params in path: /api/positions?trader_id=xxx", + }, + "body": map[string]any{ + "type": "object", + "description": "Request body; use {} for GET requests", + }, + }, + "required": []string{"method", "path", "body"}, + }, + }, +} + +// Agent is a stateful AI agent for one Telegram chat. +// It exposes a single "api_request" tool and runs a loop until the LLM +// returns a plain-text reply (no tool calls). +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 bot-made changes +// are visible in the frontend (shared 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) and returns it as a formatted string for +// injection into the LLM context at the start of each conversation. +func (a *Agent) buildAccountContext() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("[Current Account State — User: %s]\n\n", a.userID)) + + // ── AI Models ───────────────────────────────────────────────────────────── + modelsRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/models"}) + sb.WriteString("## AI Models\n") + sb.WriteString("⚠️ When creating a trader, use the EXACT \"id\" value below for \"ai_model_id\".\n") + sb.WriteString(" DO NOT use the \"provider\" field — it is NOT a valid ai_model_id.\n\n") + + var models []struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal([]byte(modelsRaw), &models); err == nil && len(models) > 0 { + for _, m := range models { + status := "disabled" + if m.Enabled { + status = "ENABLED" + } + sb.WriteString(fmt.Sprintf(" • ai_model_id=\"%s\" provider=%s name=%s [%s]\n", m.ID, m.Provider, m.Name, status)) + } + } else { + sb.WriteString(modelsRaw) + } + sb.WriteString("\n") + + // ── Exchanges ───────────────────────────────────────────────────────────── + exchangesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/exchanges"}) + sb.WriteString("## Exchanges\n") + sb.WriteString("⚠️ Use the EXACT \"id\" value below for \"exchange_id\" when creating a trader.\n\n") + + var exchanges []struct { + ID string `json:"id"` + Name string `json:"name"` + ExchangeType string `json:"exchange_type"` + AccountName string `json:"account_name"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal([]byte(exchangesRaw), &exchanges); err == nil && len(exchanges) > 0 { + for _, e := range exchanges { + status := "disabled" + if e.Enabled { + status = "ENABLED" + } + sb.WriteString(fmt.Sprintf(" • exchange_id=\"%s\" type=%s account=%s [%s]\n", e.ID, e.ExchangeType, e.AccountName, status)) + } + } else { + sb.WriteString(exchangesRaw) + } + sb.WriteString("\n") + + // ── Strategies ──────────────────────────────────────────────────────────── + strategiesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/strategies"}) + sb.WriteString("## Strategies\n") + + var strategies []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(strategiesRaw), &strategies); err == nil && len(strategies) > 0 { + for _, s := range strategies { + sb.WriteString(fmt.Sprintf(" • strategy_id=\"%s\" name=%s\n", s.ID, s.Name)) + } + } else { + sb.WriteString(strategiesRaw) + } + sb.WriteString("\n") + + // ── Traders ─────────────────────────────────────────────────────────────── + tradersRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/my-traders"}) + sb.WriteString("## Traders\n") + + var traders []struct { + TraderID string `json:"trader_id"` + Name string `json:"trader_name"` + IsRunning bool `json:"is_running"` + } + if err := json.Unmarshal([]byte(tradersRaw), &traders); err == nil && len(traders) > 0 { + for _, t := range traders { + status := "stopped" + if t.IsRunning { + status = "RUNNING" + } + sb.WriteString(fmt.Sprintf(" • trader_id=\"%s\" name=%s [%s]\n", t.TraderID, t.Name, status)) + } + } else { + sb.WriteString(tradersRaw) + } + sb.WriteString("\n") + + // ── Per-trader live data (running traders only) ──────────────────────────── + 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]:\n%s\n\n", t.Name, acct)) + stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID}) + sb.WriteString(fmt.Sprintf("Statistics [%s]:\n%s\n\n", t.Name, stats)) + } + + return sb.String() +} + +// Run processes one user message through the native function-calling agent loop. +// +// Architecture: +// - LLM receives the api_request tool definition alongside conversation history. +// - LLM response is EITHER ToolCalls (execute API) OR Content (final reply) — never both. +// This is enforced by the protocol: narration is structurally impossible. +// - Loop continues until the LLM returns a plain-text reply (no tool calls). +// +// On the first message of a conversation the live account state is fetched and injected. +// onChunk is optional; when set it is called once with the complete final reply text. +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 initial user message: prepend account state on first turn, history on subsequent turns. + histCtx := a.memory.BuildContext() + var firstUserContent string + if histCtx == "" { + accountCtx := a.buildAccountContext() + firstUserContent = accountCtx + "\n[User Message]\n" + userMessage + } else { + firstUserContent = histCtx + "\n---\nUser: " + userMessage + } + + turnMsgs := []mcp.Message{mcp.NewUserMessage(firstUserContent)} + + for i := 0; i < maxIterations; i++ { + req, err := mcp.NewRequestBuilder(). + WithSystemPrompt(a.systemPrompt). + AddConversationHistory(turnMsgs). + AddTool(apiRequestTool). + WithToolChoice("auto"). + Build() + if err != nil { + logger.Errorf("Agent: failed to build request: %v", err) + break + } + + resp, err := llm.CallWithRequestFull(req) + if err != nil { + logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err) + return "AI assistant temporarily unavailable. Please try again." + } + + // No tool calls → LLM returned a final text reply. + if len(resp.ToolCalls) == 0 { + reply := strings.TrimSpace(resp.Content) + if onChunk != nil { + onChunk(reply) + } + a.memory.Add("user", userMessage) + a.memory.Add("assistant", reply) + return reply + } + + // Tool call iteration — show thinking indicator. + if onChunk != nil { + onChunk("⏳") + } + + // Append assistant message carrying the tool calls (no content field). + turnMsgs = append(turnMsgs, mcp.Message{ + Role: "assistant", + ToolCalls: resp.ToolCalls, + }) + + // Execute each tool call and append the results as tool messages. + for _, tc := range resp.ToolCalls { + var apiReq apiRequest + if err := json.Unmarshal([]byte(tc.Function.Arguments), &apiReq); err != nil { + logger.Errorf("Agent: invalid tool args for call %s: %v", tc.ID, err) + turnMsgs = append(turnMsgs, mcp.Message{ + Role: "tool", + ToolCallID: tc.ID, + Content: fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err.Error()), + }) + continue + } + logger.Infof("Agent: iter=%d tool=%s %s %s", i+1, tc.ID, apiReq.Method, apiReq.Path) + result := a.apiTool.execute(&apiReq) + turnMsgs = append(turnMsgs, mcp.Message{ + Role: "tool", + ToolCallID: tc.ID, + Content: result, + }) + } + } + + // Safety: max iterations reached. + logger.Warnf("Agent: max iterations (%d) reached for message: %q", maxIterations, userMessage) + reply := "操作已完成,请检查您的账户查看最新状态。" + a.memory.Add("user", userMessage) + a.memory.Add("assistant", reply) + return reply +} + +// ResetMemory clears conversation history (called on /start). +func (a *Agent) ResetMemory() { + a.memory.ResetFull() +} diff --git a/telegram/agent/agent_test.go b/telegram/agent/agent_test.go new file mode 100644 index 00000000..ff2e0e41 --- /dev/null +++ b/telegram/agent/agent_test.go @@ -0,0 +1,439 @@ +package agent + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "nofx/mcp" +) + +// mockLLM implements mcp.AIClient using pre-programmed LLMResponse objects. +// Native function calling: CallWithRequestFull is the primary method; +// CallWithRequest and CallWithRequestStream are stubs kept for interface compliance. +type mockLLM struct { + responses []*mcp.LLMResponse + calls int + lastMsgs []mcp.Message +} + +func (m *mockLLM) SetAPIKey(_, _, _ string) {} +func (m *mockLLM) SetTimeout(_ time.Duration) {} + +func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return "", nil } + +func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) { + r, err := m.next() + if err != nil { + return "", err + } + return r.Content, nil +} + +func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) { + r, err := m.next() + if err != nil { + return "", err + } + if onChunk != nil { + onChunk(r.Content) + } + return r.Content, nil +} + +func (m *mockLLM) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) { + m.lastMsgs = req.Messages + return m.next() +} + +func (m *mockLLM) next() (*mcp.LLMResponse, error) { + if m.calls < len(m.responses) { + r := m.responses[m.calls] + m.calls++ + return r, nil + } + return &mcp.LLMResponse{Content: "OK"}, nil +} + +// toolCall builds a mock LLM response that contains a single tool invocation. +func toolCall(id, method, path string, body string) *mcp.LLMResponse { + if body == "" { + body = "{}" + } + return &mcp.LLMResponse{ + ToolCalls: []mcp.ToolCall{{ + ID: id, + Type: "function", + Function: mcp.ToolCallFunction{ + Name: "api_request", + Arguments: fmt.Sprintf(`{"method":%q,"path":%q,"body":%s}`, method, path, body), + }, + }}, + } +} + +// textReply builds a mock LLM response with a plain-text final answer. +func textReply(content string) *mcp.LLMResponse { + return &mcp.LLMResponse{Content: content} +} + +func mockGetLLM(llm *mockLLM) func() mcp.AIClient { + return func() mcp.AIClient { return llm } +} + +const testPrompt = "You are a test assistant." + +// mockAPIServer creates a test HTTP server with configurable route handlers. +func mockAPIServer(handlers map[string]string) (*httptest.Server, int) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + if body, ok := handlers[key]; ok { + w.Write([]byte(body)) //nolint:errcheck + return + } + // Also try path-only match (for GET) + if body, ok := handlers[r.URL.Path]; ok { + w.Write([]byte(body)) //nolint:errcheck + return + } + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"not found"}`)) //nolint:errcheck + })) + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + return srv, port +} + +// ── Basic agent behaviour ────────────────────────────────────────────────── + +// TestAgentDirectReply: LLM replies with text (no tool calls) — one LLM call. +func TestAgentDirectReply(t *testing.T) { + llm := &mockLLM{responses: []*mcp.LLMResponse{textReply("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 makes one tool call, gets result, gives final reply — two LLM calls. +func TestAgentAPICall(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/my-traders": `[{"trader_id":"t1","trader_name":"BTC Trader","is_running":false}]`, + }) + defer srv.Close() + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/my-traders", "{}"), + textReply("You have one trader: BTC Trader."), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + + reply := a.Run("list my traders", nil) + + if reply != "You have one trader: BTC Trader." { + t.Fatalf("unexpected reply: %q", reply) + } + if llm.calls != 2 { + t.Fatalf("expected 2 LLM calls, got %d", llm.calls) + } +} + +// TestAgentMultiStep: LLM chains two tool calls before final reply — three LLM calls. +func TestAgentMultiStep(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/account": `{"total_equity":1000}`, + "/api/positions": `[]`, + }) + defer srv.Close() + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/account", "{}"), + toolCall("c2", "GET", "/api/positions", "{}"), + textReply("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 tool + 1 final), got %d", llm.calls) + } + if reply != "Account looks healthy and no open positions." { + t.Fatalf("unexpected final reply: %q", reply) + } +} + +// TestAgentAPIResultInContext: tool result must appear as a tool message in the next LLM call. +func TestAgentAPIResultInContext(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/account": `{"balance":1234.56}`, + }) + defer srv.Close() + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/account", "{}"), + textReply("Balance is 1234.56 USDT."), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + a.Run("show balance", nil) + + // The last request must contain a tool-result message with the balance data. + found := false + for _, msg := range llm.lastMsgs { + if msg.Role == "tool" && strings.Contains(msg.Content, "balance") { + found = true + break + } + } + if !found { + t.Fatalf("tool result message not found in subsequent LLM context; messages: %+v", llm.lastMsgs) + } +} + +// ── Narration-free architecture tests ───────────────────────────────────── + +// TestNarrationStructurallyImpossible: when ToolCalls are present in the response, +// any Content field is ignored and never surfaced to the user. +// In real LLM APIs, Content is always empty alongside ToolCalls, but we verify +// our agent handles a malformed response defensively. +func TestNarrationStructurallyImpossible(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/strategies": `[{"id":"s1","name":"BTC Trend"}]`, + }) + defer srv.Close() + + // Simulate a (malformed) response that has both Content and ToolCalls. + malformed := &mcp.LLMResponse{ + Content: "现在我将为您查询策略。", // narration — must NOT reach user + ToolCalls: []mcp.ToolCall{{ + ID: "c1", + Type: "function", + Function: mcp.ToolCallFunction{ + Name: "api_request", + Arguments: `{"method":"GET","path":"/api/strategies","body":{}}`, + }, + }}, + } + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + malformed, + textReply("你有1个策略:BTC Trend。"), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + reply := a.Run("查询我的策略", nil) + + if strings.Contains(reply, "现在我将") { + t.Fatalf("narration leaked into final reply: %q", reply) + } + if reply != "你有1个策略:BTC Trend。" { + t.Fatalf("unexpected reply: %q", reply) + } +} + +// TestOnChunkCalledWithFinalReply: onChunk receives the complete final reply. +func TestOnChunkCalledWithFinalReply(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/account": `{"equity":500}`, + }) + defer srv.Close() + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/account", "{}"), + textReply("Equity: 500 USDT."), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + + var chunks []string + reply := a.Run("show equity", func(chunk string) { + chunks = append(chunks, chunk) + }) + + if reply != "Equity: 500 USDT." { + t.Fatalf("unexpected reply: %q", reply) + } + // Should have received ⏳ for the tool call, then the final reply. + if len(chunks) < 2 { + t.Fatalf("expected at least 2 chunks (⏳ + final), got: %v", chunks) + } + lastChunk := chunks[len(chunks)-1] + if lastChunk != "Equity: 500 USDT." { + t.Fatalf("last chunk should be final reply, got: %q", lastChunk) + } +} + +// ── Workflow tests ───────────────────────────────────────────────────────── + +// TestCreateStrategyWorkflow: simulates creating a BTC trend strategy. +// Verifies: POST strategy → GET verify → final reply shows strategy info. +func TestCreateStrategyWorkflow(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "POST /api/strategies": `{"id":"s1","name":"BTC趋势"}`, + "GET /api/strategies/s1": `{"id":"s1","name":"BTC趋势","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`, + }) + defer srv.Close() + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势","config":{}}`), + toolCall("c2", "GET", "/api/strategies/s1", "{}"), + textReply("策略已创建:BTC趋势,币种 BTC/USDT,杠杆 5x。"), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + reply := a.Run("帮我配置个btc趋势交易的策略", nil) + + if llm.calls != 3 { + t.Fatalf("expected 3 LLM calls, got %d", llm.calls) + } + if reply == "" { + t.Fatalf("empty final reply") + } +} + +// TestFullSetupWorkflow: create strategy → verify → create trader → start trader. +// This is the "帮我配置策略并跑起来" workflow. +func TestFullSetupWorkflow(t *testing.T) { + calls := map[string]int{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + calls[key]++ + switch key { + case "POST /api/strategies": + w.Write([]byte(`{"id":"s1","name":"BTC趋势"}`)) //nolint:errcheck + case "GET /api/strategies/s1": + w.Write([]byte(`{"id":"s1","name":"BTC趋势","config":{}}`)) //nolint:errcheck + case "POST /api/traders": + w.Write([]byte(`{"id":"tr1","name":"BTC趋势交易员"}`)) //nolint:errcheck + case "POST /api/traders/tr1/start": + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势"}`), + toolCall("c2", "GET", "/api/strategies/s1", "{}"), + toolCall("c3", "POST", "/api/traders", `{"name":"BTC趋势交易员","strategy_id":"s1"}`), + toolCall("c4", "POST", "/api/traders/tr1/start", "{}"), + textReply("策略和交易员已创建并启动!BTC趋势交易员正在运行。"), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + reply := a.Run("帮我配置个btc趋势交易的策略交易 跑起来", nil) + + if llm.calls != 5 { + t.Fatalf("expected 5 LLM calls, got %d", llm.calls) + } + if calls["POST /api/strategies"] != 1 { + t.Errorf("expected 1 POST /api/strategies, got %d", calls["POST /api/strategies"]) + } + if calls["POST /api/traders"] != 1 { + t.Errorf("expected 1 POST /api/traders, got %d", calls["POST /api/traders"]) + } + if calls["POST /api/traders/tr1/start"] != 1 { + t.Errorf("expected 1 POST /api/traders/tr1/start, got %d", calls["POST /api/traders/tr1/start"]) + } + if reply == "" { + t.Fatalf("empty final reply") + } +} + +// TestStartExistingTrader: when trader already exists, just start it. +func TestStartExistingTrader(t *testing.T) { + calls := map[string]int{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Method + " " + r.URL.Path + calls[key]++ + switch key { + case "GET /api/my-traders": + w.Write([]byte(`[{"trader_id":"tr1","trader_name":"BTC Trader","is_running":false}]`)) //nolint:errcheck + case "POST /api/traders/tr1/start": + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + var port int + fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/my-traders", "{}"), + toolCall("c2", "POST", "/api/traders/tr1/start", "{}"), + textReply("交易员 BTC Trader 已启动。"), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + reply := a.Run("启动交易员", nil) + + if calls["POST /api/traders/tr1/start"] != 1 { + t.Errorf("expected trader to be started, got %d start calls", calls["POST /api/traders/tr1/start"]) + } + if reply != "交易员 BTC Trader 已启动。" { + t.Fatalf("unexpected reply: %q", reply) + } +} + +// ── Safety limit ─────────────────────────────────────────────────────────── + +// TestMaxIterations: agent terminates after maxIterations and returns fallback message. +func TestMaxIterations(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/account": `{"ok":true}`, + }) + defer srv.Close() + + // Always returns another tool call — should hit max iterations. + responses := make([]*mcp.LLMResponse, maxIterations+2) + for i := range responses { + responses[i] = toolCall(fmt.Sprintf("c%d", i), "GET", "/api/account", "{}") + } + + llm := &mockLLM{responses: responses} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + reply := a.Run("loop forever", nil) + + if reply == "" { + t.Fatalf("expected a fallback reply, got empty string") + } + // Agent should have made exactly maxIterations tool-call LLM calls. + if llm.calls != maxIterations { + t.Fatalf("expected %d LLM calls (max iterations), got %d", maxIterations, llm.calls) + } +} + +// TestToolCallIDPropagated: tool result messages carry the correct ToolCallID. +func TestToolCallIDPropagated(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/account": `{"balance":999}`, + }) + defer srv.Close() + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("call-xyz-123", "GET", "/api/account", "{}"), + textReply("Balance is 999."), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + a.Run("check balance", nil) + + // Find the tool result message and verify ToolCallID matches. + found := false + for _, msg := range llm.lastMsgs { + if msg.Role == "tool" && msg.ToolCallID == "call-xyz-123" { + found = true + break + } + } + if !found { + t.Fatalf("tool result with ToolCallID='call-xyz-123' not found in messages: %+v", llm.lastMsgs) + } +} diff --git a/telegram/agent/apicall.go b/telegram/agent/apicall.go new file mode 100644 index 00000000..eca6b9d5 --- /dev/null +++ b/telegram/agent/apicall.go @@ -0,0 +1,88 @@ +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 holds the arguments decoded from the LLM's api_request tool call. +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) +} + diff --git a/telegram/agent/manager.go b/telegram/agent/manager.go new file mode 100644 index 00000000..d461c28b --- /dev/null +++ b/telegram/agent/manager.go @@ -0,0 +1,79 @@ +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. +// userEmail is the registered email shown to the user when they ask "who am I". +// userID is the internal DB UUID used for API authentication. +func NewManager(apiPort int, botToken, userEmail, 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, userEmail, userID), + } +} + +// Run processes a message for the given chat ID. +// If the same chat is already processing a message, this call blocks until it completes +// or the lane wait times out (60 s), whichever comes first. +// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming). +func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string { + a, lane := m.getOrCreate(chatID) + select { + case lane <- struct{}{}: + case <-time.After(60 * time.Second): + logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID) + return "上一条消息仍在处理中,请稍等片刻后再试。" + } + defer func() { <-lane }() + return a.Run(userMessage, onChunk) +} + +// Reset clears memory for the given chat (called on /start). +func (m *Manager) Reset(chatID int64) { + m.mu.Lock() + a, ok := m.agents[chatID] + m.mu.Unlock() + if ok { + a.ResetMemory() + } +} + +func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.agents[chatID] + if !ok { + a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt) + m.agents[chatID] = a + } + lane, ok := m.lanes[chatID] + if !ok { + lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat + m.lanes[chatID] = lane + } + return a, lane +} diff --git a/telegram/agent/prompt.go b/telegram/agent/prompt.go new file mode 100644 index 00000000..c54bf5ee --- /dev/null +++ b/telegram/agent/prompt.go @@ -0,0 +1,97 @@ +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. +// userEmail is the registered email of the bound user (shown when user asks "who am I"). +// userID is the internal DB UUID used for API authentication only. +func BuildAgentPrompt(apiDocs, userEmail, userID string) string { + return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant. + +## Your Identity +- You are operating as: %s +- Internal user ID (for API calls only): %s +- When asked "which user / account / email" — answer with the email address above +- All API calls are made on behalf of this user + +## Tool: api_request +Use the api_request tool to call the NOFX REST API: +- method: "GET" | "POST" | "PUT" | "DELETE" +- path: API path; query params go in the path: /api/positions?trader_id=xxx +- body: JSON object (use {} for GET requests) + +## NOFX API Documentation + +%s + +## CRITICAL: Exact ID Rule (read this before every API call) +API fields like "ai_model_id", "exchange_id", "strategy_id", "trader_id" require the EXACT "id" value +from the corresponding API response. NEVER use "provider", "type", or any other field as a substitute. + +Wrong: {"ai_model_id": "deepseek"} ← "deepseek" is the provider, NOT the id +Correct: {"ai_model_id": "abc123_deepseek"} ← full "id" from GET /api/models + +The Account State block at the start of this conversation lists every resource with its exact id. +Read the id field from there and copy it verbatim — do not abbreviate, shorten, or guess. + +## Behavior Rules +1. Reply in the same language the user used (中文→中文, English→English) +2. Keep final replies concise — show results, not process +3. Ask for ALL missing required info in ONE message — never ask one field at a time +4. When user provides enough info, act immediately — no confirmation needed +5. 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 — you may have used the wrong ID format; check the Account State for the exact id +- "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 + +## 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 + +**Create strategy** (independent from traders): +- Never GET trader info just to create a strategy. +- POST {"name":""} — config is OPTIONAL. Backend applies complete working defaults automatically (ai500 top coins, all indicators, standard risk control). Strategy is immediately usable. +- Only include "config" when user explicitly requests custom settings (specific coins, custom leverage, different timeframes). +- After POST: GET /api/strategies/:id to verify → show user: name, coin_source.source_type, key risk_control values + +**"帮我配置策略并跑起来" / "create strategy and start" (full setup workflow)**: +Execute these steps IN ORDER with NO user confirmation between them: +1. POST /api/strategies — body: {"name":""} — no config needed, defaults are complete +2. GET /api/strategies/:id — verify strategy was saved +3. POST /api/traders — create trader: use exchange_id and model_id from Account State (if only one each, use directly); set strategy_id from step 1; set name matching the strategy +4. POST /api/traders/:id/start — start the trader +5. Final reply: show strategy name, trader name, coin source, confirm running + +**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 existing trader**: From Account State, if only one trader, act directly. If multiple, list and ask. + +**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userEmail, userID, apiDocs) +} diff --git a/telegram/bot.go b/telegram/bot.go new file mode 100644 index 00000000..e80e4c5a --- /dev/null +++ b/telegram/bot.go @@ -0,0 +1,479 @@ +package telegram + +import ( + "nofx/api" + "nofx/config" + "nofx/logger" + "nofx/mcp" + "nofx/store" + "nofx/telegram/agent" + "os" + "strings" + "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. +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...") + <-reloadCh + continue + } + + stopped := runBot(token, cfg, st) + if !stopped { + return + } + + select { + case <-reloadCh: + logger.Info("Reloading Telegram bot with new token...") + } + } +} + +// resolveToken returns the bot token from DB (configured via Web UI). +func resolveToken(cfg *config.Config, st *store.Store) string { + dbCfg, err := st.TelegramConfig().Get() + if err == nil && dbCfg.BotToken != "" { + return dbCfg.BotToken + } + return "" +} + +// runBot runs the bot until the updates channel closes (clean stop → true) or a fatal error (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", bot.Self.UserName) + + // Allowed chat ID: read from DB binding (0 = unbound, first /start will bind). + allowedChatID := int64(0) + if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 { + allowedChatID = id + } + + // botUserID / botToken / agents are resolved lazily and refresh when user registers. + var ( + botUserID string + botUserEmail string + botToken string + agents *agent.Manager + ) + + resolveBotUser := func() bool { + users, err := st.User().GetAll() + if err != nil || len(users) == 0 { + return false + } + u := users[0] + if u.ID == botUserID { + return true + } + newToken, err := agent.GenerateBotToken(u.ID) + if err != nil { + logger.Errorf("Failed to generate bot JWT for user %s: %v", u.ID, err) + return false + } + prev := botUserID + botUserID = u.ID + botUserEmail = u.Email + botToken = newToken + agents = agent.NewManager(cfg.APIServerPort, botToken, botUserEmail, botUserID, + func() mcp.AIClient { return newLLMClient(st, botUserID) }, + api.GetAPIDocs(), + ) + if prev == "" { + logger.Infof("Bot: resolved user %s (%s)", botUserID, botUserEmail) + } else { + logger.Infof("Bot: user changed → %s (%s)", botUserID, botUserEmail) + } + return true + } + resolveBotUser() + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + updates := bot.GetUpdatesChan(u) + + // awaitingLang is set only when the user explicitly runs /lang. + awaitingLang := false + + for update := range updates { + if update.Message == nil { + continue + } + chatID := update.Message.Chat.ID + text := strings.TrimSpace(update.Message.Text) + + // ── Language selection (triggered only by /lang) ────────────────────── + if awaitingLang && chatID == allowedChatID { + if lang := parseLangChoice(text); lang != "" { + awaitingLang = false + st.TelegramConfig().SetLanguage(lang) //nolint:errcheck + sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang)) + } else { + sendMarkdownMsg(bot, chatID, langMenuMsg()) + } + continue + } + + // ── /start ──────────────────────────────────────────────────────────── + if text == "/start" { + resolveBotUser() + if botUserID == "" { + sendMsg(bot, chatID, + "No account found.\nOpen the web dashboard to register, then send /start.") + continue + } + if allowedChatID == 0 { + 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 try again.") + continue + } + allowedChatID = chatID + logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID) + } else if chatID != allowedChatID { + sendMsg(bot, chatID, "This bot is already bound to another account.") + continue + } else { + agents.Reset(chatID) + } + lang := st.TelegramConfig().GetLanguage() + sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang)) + continue + } + + // ── /lang ───────────────────────────────────────────────────────────── + if text == "/lang" { + awaitingLang = true + sendMarkdownMsg(bot, chatID, langMenuMsg()) + continue + } + + // ── /help ───────────────────────────────────────────────────────────── + if text == "/help" { + lang := st.TelegramConfig().GetLanguage() + sendMarkdownMsg(bot, chatID, helpMsg(lang)) + continue + } + + // ── Access control ──────────────────────────────────────────────────── + if allowedChatID != 0 && chatID != allowedChatID { + sendMsg(bot, chatID, "Unauthorized.") + continue + } + if allowedChatID == 0 { + sendMsg(bot, chatID, "Send /start first.") + continue + } + if text == "" { + continue + } + + // ── Refresh user before every AI call ──────────────────────────────── + resolveBotUser() + if botUserID == "" { + sendMsg(bot, chatID, "No account found. Open the web dashboard to register.") + continue + } + + lang := st.TelegramConfig().GetLanguage() + + // ── Guard: show status if not ready for trading ─────────────────────── + if newLLMClient(st, botUserID) == nil { + sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang)) + continue + } + + // ── AI agent ───────────────────────────────────────────────────────── + go func(chatID int64, text string) { + sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳")) + placeholderID := 0 + if err == nil { + placeholderID = sent.MessageID + } + + var ( + mu sync.Mutex + lastEdit time.Time + ) + onChunk := func(accumulated string) { + if placeholderID == 0 { + return + } + mu.Lock() + defer mu.Unlock() + if accumulated != "⏳" && 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) + + 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) + } + + return true +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + bot.Send(msg) //nolint:errcheck +} + +func sendMarkdownMsg(bot *tgbotapi.BotAPI, chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + if _, err := bot.Send(msg); err != nil { + plain := tgbotapi.NewMessage(chatID, text) + bot.Send(plain) //nolint:errcheck + } +} + +// ── LLM client ─────────────────────────────────────────────────────────────── + +func newLLMClient(st *store.Store, userID string) mcp.AIClient { + // 1. Prefer the model explicitly configured for Telegram (Settings → Telegram → AI Model) + if tgCfg, err := st.TelegramConfig().Get(); err == nil && tgCfg.ModelID != "" { + if model, err := st.AIModel().Get(userID, tgCfg.ModelID); err == nil && model.Enabled { + apiKey := string(model.APIKey) + if apiKey != "" { + client := clientForProvider(model.Provider) + client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName) + if isUSDCProvider(model.Provider) { + logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID) + } else { + logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID) + } + return client + } + } + } + + // 2. Fall back to first enabled model + if model, err := st.AIModel().GetDefault(userID); err == nil { + apiKey := string(model.APIKey) + if apiKey != "" { + client := clientForProvider(model.Provider) + client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName) + if isUSDCProvider(model.Provider) { + logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID) + } else { + logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID) + } + return client + } + } + + // 3. Environment variable fallback + 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, "") + return client + } + } + return nil +} + +// isUSDCProvider returns true for providers that pay per call with USDC (x402 protocol). +func isUSDCProvider(provider string) bool { + return provider == "blockrun-base" || provider == "blockrun-sol" || provider == "claw402" +} + +func clientForProvider(provider string) mcp.AIClient { + switch provider { + case "openai": + return mcp.NewOpenAIClient() + case "deepseek": + return mcp.NewDeepSeekClient() + case "claude": + return mcp.NewClaudeClient() + case "qwen": + return mcp.NewQwenClient() + case "kimi": + return mcp.NewKimiClient() + case "grok": + return mcp.NewGrokClient() + case "gemini": + return mcp.NewGeminiClient() + case "minimax": + return mcp.NewMiniMaxClient() + case "blockrun-base": + return mcp.NewBlockRunBaseClient() + case "blockrun-sol": + return mcp.NewBlockRunSolClient() + case "claw402": + return mcp.NewClaw402Client() + default: + return mcp.NewDeepSeekClient() + } +} + +// ── Status message ──────────────────────────────────────────────────────────── + +// statusMsg is the single entry-point message shown after /start. +// It checks what's configured and shows either a setup prompt or the ready state. +func statusMsg(st *store.Store, userID string, apiPort int, lang string) string { + webURL := "http://localhost:3000" + + // Determine what's missing. + hasModel := false + if _, err := st.AIModel().GetDefault(userID); err == nil { + hasModel = true + } + + hasExchange := false + if exchanges, err := st.Exchange().List(userID); err == nil { + for _, e := range exchanges { + if e.Enabled { + hasExchange = true + break + } + } + } + + if !hasModel || !hasExchange { + missing := "" + if lang == "zh" { + if !hasModel { + missing += "\n❌ AI 模型 → 设置 → AI 模型 → 添加" + } + if !hasExchange { + missing += "\n❌ 交易所 → 设置 → 交易所 → 添加" + } + return "⚙️ *需要完成初始配置*\n\n打开 Web 管理界面完成配置:\n→ " + webURL + "\n" + missing + "\n\n配置完成后发送 /start" + } + if !hasModel { + missing += "\n❌ AI Model → Settings → AI Models → Add" + } + if !hasExchange { + missing += "\n❌ Exchange → Settings → Exchanges → Add" + } + return "⚙️ *Setup required*\n\nOpen the web dashboard to complete setup:\n→ " + webURL + "\n" + missing + "\n\nSend /start when done." + } + + // All configured — show ready state. + if lang == "zh" { + return `✅ *NOFX 就绪,开始交易吧!* + +直接告诉我你想做什么: + +📊 "查看我的持仓" +💰 "账户余额多少" +🤖 "帮我创建 BTC 趋势策略并启动" +⏹ "停止所有交易员" + +/help 查看更多 · /lang 切换语言` + } + return `✅ *NOFX is ready!* + +Just tell me what you want: + +📊 "Show my positions" +💰 "What's my balance?" +🤖 "Create a BTC trend strategy and start it" +⏹ "Stop all traders" + +/help for more · /lang to change language` +} + +// ── Language ────────────────────────────────────────────────────────────────── + +func langMenuMsg() string { + return "🌐 *Choose your language*\n\n1 — English\n2 — 中文\n\nReply with 1 or 2" +} + +func parseLangChoice(text string) string { + switch strings.TrimSpace(text) { + case "1", "en", "EN", "English", "english": + return "en" + case "2", "zh", "ZH", "中文", "chinese", "Chinese": + return "zh" + } + return "" +} + +// ── Help ────────────────────────────────────────────────────────────────────── + +func helpMsg(lang string) string { + if lang == "zh" { + return `*NOFX 使用指南* + +*查询* +• "查看我的持仓" +• "账户余额多少" +• "列出我的交易员" + +*创建 & 启动* +• "帮我创建 BTC 趋势策略并跑起来" +• "保守型策略,只交易 BTC 和 ETH" + +*控制* +• "启动交易员" +• "暂停交易员" +• "停止所有交易" + +*命令* +/start — 刷新状态 +/lang — 切换语言 +/help — 帮助` + } + return `*NOFX Help* + +*Query* +• "Show my positions" +• "What's my balance?" +• "List my traders" + +*Create & start* +• "Create a BTC trend strategy and start it" +• "Conservative strategy, BTC and ETH only" + +*Control* +• "Start trader" +• "Stop trader" +• "Stop all trading" + +*Commands* +/start — refresh status +/lang — change language +/help — show this` +} diff --git a/telegram/session/memory.go b/telegram/session/memory.go new file mode 100644 index 00000000..30e84b2c --- /dev/null +++ b/telegram/session/memory.go @@ -0,0 +1,105 @@ +package session + +import ( + "fmt" + "nofx/mcp" + "strings" +) + +const ( + compactionThresholdTokens = 3000 + charsPerToken = 3 // rough estimate for token counting +) + +type Message struct { + Role string // "user" or "assistant" + Content string +} + +// Memory manages conversation history with automatic compaction. +// Inspired by openclaw's compaction pattern: +// when ShortTerm exceeds threshold, LLM silently summarizes it into LongTerm. +type Memory struct { + LongTerm string // Durable summary (survives compaction, user never sees this happen) + ShortTerm []Message // Recent conversation (cleared on compaction) + llm mcp.AIClient +} + +func NewMemory(llm mcp.AIClient) *Memory { + return &Memory{llm: llm} +} + +// Add appends a message and triggers compaction if threshold exceeded +func (m *Memory) Add(role, content string) { + m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content}) + if m.estimateTokens() > compactionThresholdTokens { + m.compact() + } +} + +// BuildContext returns context string for the agent's conversation history. +func (m *Memory) BuildContext() string { + var sb strings.Builder + if m.LongTerm != "" { + sb.WriteString("[Summary of earlier conversation]\n") + sb.WriteString(m.LongTerm) + sb.WriteString("\n\n") + } + if len(m.ShortTerm) > 0 { + sb.WriteString("[Recent conversation]\n") + for _, msg := range m.ShortTerm { + sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content)) + } + } + return sb.String() +} + +// Reset clears short-term history (LongTerm preserved intentionally) +func (m *Memory) Reset() { + m.ShortTerm = []Message{} +} + +// ResetFull clears everything including long-term memory +func (m *Memory) ResetFull() { + m.ShortTerm = []Message{} + m.LongTerm = "" +} + +func (m *Memory) estimateTokens() int { + total := len(m.LongTerm) + for _, msg := range m.ShortTerm { + total += len(msg.Content) + } + return total / charsPerToken +} + +// compact summarizes short-term history into long-term memory. +// This runs silently - the user never sees it happen. +// If LLM call fails, short-term is preserved as-is (no data loss). +func (m *Memory) compact() { + if m.llm == nil || len(m.ShortTerm) == 0 { + return + } + history := m.BuildContext() + systemPrompt := `You are a conversation summarizer. Compress the following trading assistant conversation into a concise summary. + +Must preserve: +- What the user is configuring (strategy/exchange/model/trader) +- Confirmed parameters (trading pairs, leverage, stop loss, indicators, etc.) +- Pending or missing parameters +- User preferences and requirements + +Output: plain text summary, under 200 words.` + + summary, err := m.llm.CallWithMessages(systemPrompt, history) + if err != nil { + // Compaction failed: keep short-term as-is, never lose user data + return + } + if m.LongTerm != "" { + m.LongTerm = m.LongTerm + "\n" + summary + } else { + m.LongTerm = summary + } + m.ShortTerm = []Message{} +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index d2b1b905..3ee4777b 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -216,6 +216,11 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName) logger.Infof("🤖 [%s] Using BlockRun (Solana Wallet) AI", config.Name) + case "claw402": + mcpClient = mcp.NewClaw402Client() + mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName) + logger.Infof("🤖 [%s] Using Claw402 (Base USDC) AI", config.Name) + case "qwen": mcpClient = mcp.NewQwenClient() apiKey := config.QwenKey diff --git a/web/public/icons/claw402.png b/web/public/icons/claw402.png new file mode 100644 index 00000000..613eaae0 Binary files /dev/null and b/web/public/icons/claw402.png differ diff --git a/web/public/icons/claw402.svg b/web/public/icons/claw402.svg new file mode 100644 index 00000000..6cb43ac7 --- /dev/null +++ b/web/public/icons/claw402.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + $ + diff --git a/web/src/App.tsx b/web/src/App.tsx index fb7fa602..240295a8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,7 +6,8 @@ import { TraderDashboardPage } from './pages/TraderDashboardPage' import { AITradersPage } from './components/AITradersPage' import { LoginPage } from './components/LoginPage' -import { RegisterPage } from './components/RegisterPage' +import { SetupPage } from './components/SetupPage' +import { SettingsPage } from './pages/SettingsPage' import { ResetPasswordPage } from './components/ResetPasswordPage' import { CompetitionPage } from './components/CompetitionPage' import { LandingPage } from './pages/LandingPage' @@ -53,7 +54,7 @@ type Page = function App() { const { language, setLanguage } = useLanguage() const { user, token, logout, isLoading } = useAuth() - const { loading: configLoading } = useSystemConfig() + const { config: systemConfig, loading: configLoading } = useSystemConfig() const [route, setRoute] = useState(window.location.pathname) // Debug log @@ -341,12 +342,22 @@ function App() { ) } + // First-time setup: redirect to /setup if system not initialized + if (systemConfig && !systemConfig.initialized && !user) { + return + } + // Handle specific routes regardless of authentication if (route === '/login') { return } - if (route === '/register') { - return + if (route === '/setup') { + // If already initialized, redirect to login + if (systemConfig?.initialized) { + window.location.href = '/login' + return null + } + return } if (route === '/faq') { return ( @@ -376,6 +387,26 @@ function App() { if (route === '/reset-password') { return } + if (route === '/settings') { + if (!user || !token) { + window.location.href = '/login' + return null + } + return ( +
+ + +
+ ) + } // Data page - publicly accessible with embedded dashboard if (route === '/data') { const dataPageNavigate = (page: Page) => { diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 72dfaf78..422f88d1 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -16,6 +16,7 @@ import { getModelIcon } from './ModelIcons' import { TraderConfigModal } from './TraderConfigModal' import { DeepVoidBackground } from './DeepVoidBackground' import { ExchangeConfigModal } from './traders/ExchangeConfigModal' +import { TelegramConfigModal } from './traders/TelegramConfigModal' import { PunkAvatar, getTraderAvatar } from './PunkAvatar' import { Bot, @@ -31,6 +32,7 @@ import { ExternalLink, Copy, Check, + MessageCircle, } from 'lucide-react' import { confirmToast } from '../lib/notify' import { toast } from 'sonner' @@ -65,6 +67,22 @@ const BLOCKRUN_MODELS = [ { id: 'minimax-m2.5', name: 'MiniMax M2.5', desc: 'MiniMax · Flagship' }, ] +// Models available through Claw402 (x402 USDC payment protocol) +const CLAW402_MODELS = [ + { id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: 'Flagship · Fast', icon: '⚡' }, + { id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: 'Reasoning · Pro', icon: '🧠' }, + { id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: 'Balanced', icon: '💡' }, + { id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: 'Fast · Cheap', icon: '🚀' }, + { id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: 'Flagship · Deep', icon: '🎯' }, + { id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: 'Best Value', icon: '🔥' }, + { id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: 'Reasoning', icon: '🤔' }, + { id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: 'Flagship', icon: '🌟' }, + { id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: 'Balanced', icon: '✨' }, + { id: 'grok-4.1', name: 'Grok 4.1', provider: 'xAI', desc: 'Flagship', icon: '⚡' }, + { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: 'Flagship', icon: '💎' }, + { id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: 'Balanced', icon: '🌙' }, +] + // AI Provider configuration - default models and API links const AI_PROVIDER_CONFIG: Record(null) const [editingExchange, setEditingExchange] = useState(null) const [editingTrader, setEditingTrader] = useState(null) @@ -874,6 +898,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { + + + )} +
- {availableModels.filter(m => !m.provider?.startsWith('blockrun')).map((model) => ( + {availableModels.filter(m => !m.provider?.startsWith('blockrun') && m.provider !== 'claw402').map((model) => ( )} - {/* Step 1: Configure */} - {(currentStep === 1 || editingModelId) && selectedModel && ( + {/* Step 1: Configure — Claw402 Dedicated UI */} + {(currentStep === 1 || editingModelId) && selectedModel && (selectedModel.provider === 'claw402' || selectedModel.id === 'claw402') && ( +
+ {/* Claw402 Hero Header */} +
+
+ Claw402 +
+
+ Claw402 +
+
+ {language === 'zh' + ? '用 USDC 按次付费,支持所有主流 AI 模型' + : 'Pay-per-call with USDC — supports all major AI models'} +
+
+ {['GPT', 'Claude', 'DeepSeek', 'Gemini', 'Grok', 'Qwen', 'Kimi'].map(name => ( + + {name} + + ))} +
+
+ + {/* Step 1: Select AI Model */} +
+ +
+ {language === 'zh' + ? '所有模型通过 Claw402 统一调用,创建后可随时切换' + : 'All models unified via Claw402. Switch anytime after setup.'} +
+
+ {CLAW402_MODELS.map((m) => { + const isSelected = (modelName || 'deepseek') === m.id + return ( + + ) + })} +
+
+ + {/* Step 2: Wallet Setup */} +
+ + +
+
+ {language === 'zh' + ? '💡 Claw402 使用 Base 链上的 USDC 付费,你需要一个 EVM 钱包' + : '💡 Claw402 uses USDC on Base chain. You need an EVM wallet.'} +
+
+
+ + {language === 'zh' + ? '可以用 MetaMask、Rabby 等钱包导出私钥' + : 'Export private key from MetaMask, Rabby, etc.'} +
+
+ + {language === 'zh' + ? '建议新建一个专用钱包,充入少量 USDC 即可' + : 'Recommended: create a dedicated wallet with a small USDC balance'} +
+
+
+ +
+
+ {language === 'zh' ? '钱包私钥(Base 链 EVM)' : 'Wallet Private Key (Base Chain EVM)'} +
+ setApiKey(e.target.value)} + placeholder="0x..." + className="w-full px-4 py-3 rounded-xl font-mono text-sm" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
+ 🔒 + + {language === 'zh' + ? '私钥仅在本地签名使用,不会上传或发送交易。无需 ETH,无 Gas 费用。' + : 'Private key is only used locally for signing. Never uploaded. No ETH or gas needed.'} + +
+
+
+ + {/* USDC Recharge Guide */} +
+
+ 💰 {language === 'zh' ? '如何充值 USDC' : 'How to Fund USDC'} +
+
+
+ 1. + {language === 'zh' ? '从交易所(Binance / OKX / Coinbase)提 USDC 到你的钱包地址' : 'Withdraw USDC from exchange (Binance/OKX/Coinbase) to your wallet'} +
+
+ 2. + {language === 'zh' ? '选择 Base 网络(手续费极低)' : 'Select Base network (very low fees)'} +
+
+ 3. + {language === 'zh' ? '充入 $5-10 USDC 即可使用很长时间(约 $0.003/次调用)' : '$5-10 USDC lasts a long time (~$0.003/call)'} +
+
+
+ + {/* Buttons */} +
+ + +
+
+ )} + + {/* Step 1: Configure — Standard Providers (non-claw402) */} + {(currentStep === 1 || editingModelId) && selectedModel && selectedModel.provider !== 'claw402' && selectedModel.id !== 'claw402' && (
{/* Selected Model Header */}
diff --git a/web/src/components/HeaderBar.tsx b/web/src/components/HeaderBar.tsx index bc7ebdd6..b5ef43f7 100644 --- a/web/src/components/HeaderBar.tsx +++ b/web/src/components/HeaderBar.tsx @@ -1,9 +1,8 @@ import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' -import { Menu, X, ChevronDown } from 'lucide-react' +import { Menu, X, ChevronDown, Settings } from 'lucide-react' import { t, type Language } from '../i18n/translations' -import { useSystemConfig } from '../hooks/useSystemConfig' import { OFFICIAL_LINKS } from '../constants/branding' type Page = @@ -49,9 +48,6 @@ export default function HeaderBar({ const [userDropdownOpen, setUserDropdownOpen] = useState(false) const dropdownRef = useRef(null) const userDropdownRef = useRef(null) - const { config: systemConfig } = useSystemConfig() - const registrationEnabled = systemConfig?.registration_enabled !== false - // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -214,6 +210,16 @@ export default function HeaderBar({ {user.email}
+ {onLogout && ( - - - {/* Terminal Header */} -
-
-
-
- NoFx Logo -
-
-

- SYSTEM ACCESS -

-

- Authentication Protocol v3.0 -

-
- - {/* Terminal Output / Form Container */} -
-
- - {/* Window Bar */} -
-
-
window.location.href = '/'} - title="Close / Return Home" - >
-
-
-
-
- login.exe + {/* Logo + Title */} +
+
+
+
+ NOFX +
+

Welcome back

+

Sign in to your account

-
- {/* Status Output */} -
-
- - Initiating handshake... -
-
- - Target: NOFX CORE HUB -
-
- - Status: AWAITING CREDENTIALS -
-
+ {/* Card */} +
+ - {adminMode ? ( - -
- + {/* Email */} +
+ + setEmail(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="you@example.com" + required + autoFocus + /> +
+ + {/* Password */} +
+
+ + +
+
setAdminPassword(e.target.value)} - className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono" - placeholder="ENTER_ROOT_PASSWORD" + type={showPassword ? 'text' : 'password'} + value={password} + onChange={(e) => setPassword(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="••••••••" required /> +
+
- {error && ( -
- [ERROR]: {error} -
- )} + {/* Error */} + {error && ( +

+ {error} +

+ )} - - - ) : ( -
-
-
- - setEmail(e.target.value)} - className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono" - placeholder="user@nofx.os" - required - /> -
- -
-
- -
- -
- setPassword(e.target.value)} - className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-700 text-white font-mono pr-10" - placeholder="••••••••••••" - required - /> - -
-
- -
-
-
- - {error && ( -
- {error} -
- )} - - -
- )} -
- - {/* Terminal Footer Info */} -
-
SECURE_CONNECTION: ENCRYPTED
-
{new Date().toISOString().split('T')[0]}
-
-
- - {/* Register Link */} - {!adminMode && registrationEnabled && ( -
-

- NEW_USER_DETECTED?{' '} + {/* Submit */} -

- +
- )} + +
) diff --git a/web/src/components/ModelIcons.tsx b/web/src/components/ModelIcons.tsx index 42721810..e51a3e7e 100644 --- a/web/src/components/ModelIcons.tsx +++ b/web/src/components/ModelIcons.tsx @@ -16,6 +16,7 @@ const MODEL_COLORS: Record = { minimax: '#E45735', 'blockrun-base': '#2563EB', 'blockrun-sol': '#9945FF', + claw402: '#7C3AED', } // 获取AI模型图标的函数 @@ -54,6 +55,9 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => { case 'blockrun-sol': iconPath = '/icons/blockrun.svg' break + case 'claw402': + iconPath = '/icons/claw402.png' + break default: return null } diff --git a/web/src/components/RegisterPage.tsx b/web/src/components/RegisterPage.tsx index 55182bac..7485501b 100644 --- a/web/src/components/RegisterPage.tsx +++ b/web/src/components/RegisterPage.tsx @@ -30,7 +30,7 @@ export function RegisterPage() { getSystemConfig() .then((config) => { setBetaMode(config.beta_mode || false) - setRegistrationEnabled(config.registration_enabled !== false) + setRegistrationEnabled(config.initialized === false) }) .catch((err) => { console.error('Failed to fetch system config:', err) diff --git a/web/src/components/SetupPage.tsx b/web/src/components/SetupPage.tsx new file mode 100644 index 00000000..5ad87df7 --- /dev/null +++ b/web/src/components/SetupPage.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react' +import { Eye, EyeOff } from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { DeepVoidBackground } from './DeepVoidBackground' +import { invalidateSystemConfig } from '../lib/config' + +export function SetupPage() { + const { register } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + if (password.length < 8) { + setError('Password must be at least 8 characters') + return + } + setLoading(true) + const result = await register(email, password) + setLoading(false) + if (result.success) { + invalidateSystemConfig() + window.location.href = '/traders' + } else { + setError(result.message || 'Setup failed, please try again') + } + } + + return ( + +
+
+ + {/* Logo + Title */} +
+
+
+
+ NOFX +
+
+

Welcome to NOFX

+

Create your account to get started

+
+ + {/* Card */} +
+
+ + {/* Email */} +
+ + setEmail(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="you@example.com" + required + autoFocus + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="At least 8 characters" + required + /> + +
+
+ + {/* Error */} + {error && ( +

+ {error} +

+ )} + + {/* Submit */} + +
+
+ +

+ Single-user system — this is the only account +

+
+
+ + ) +} diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index 417b29cb..c1c5a27a 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -337,8 +337,8 @@ export function TraderConfigModal({ {strategies.map((strategy) => ( ))} diff --git a/web/src/components/landing/LoginModal.tsx b/web/src/components/landing/LoginModal.tsx index 3abaf1b8..abb9e3bc 100644 --- a/web/src/components/landing/LoginModal.tsx +++ b/web/src/components/landing/LoginModal.tsx @@ -1,16 +1,12 @@ import { motion } from 'framer-motion' import { X } from 'lucide-react' import { t, Language } from '../../i18n/translations' -import { useSystemConfig } from '../../hooks/useSystemConfig' - interface LoginModalProps { onClose: () => void language: Language } export default function LoginModal({ onClose, language }: LoginModalProps) { - const { config: systemConfig } = useSystemConfig() - const registrationEnabled = systemConfig?.registration_enabled !== false return ( {t('signIn', language)} - {registrationEnabled && ( - { - window.history.pushState({}, '', '/register') - window.dispatchEvent(new PopStateEvent('popstate')) - onClose() - }} - className="block w-full px-6 py-3 rounded-lg font-semibold text-center" - style={{ - background: 'var(--brand-dark-gray)', - color: 'var(--brand-light-gray)', - border: '1px solid rgba(240, 185, 11, 0.2)', - }} - whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }} - whileTap={{ scale: 0.95 }} - > - {t('registerNewAccount', language)} - - )}
diff --git a/web/src/components/traders/TelegramConfigModal.tsx b/web/src/components/traders/TelegramConfigModal.tsx new file mode 100644 index 00000000..bf170daf --- /dev/null +++ b/web/src/components/traders/TelegramConfigModal.tsx @@ -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 ( +
+ {labels.map((label, index) => ( + +
+
+ {index < currentStep ? : index + 1} +
+ + {label} + +
+ {index < labels.length - 1 && ( +
+ )} + + ))} +
+ ) +} + +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(null) + const [models, setModels] = useState([]) + 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 = () => ( +
+ + {models.length === 0 ? ( +
+ {zh ? '暂无启用的模型,请先在「AI 模型」中配置' : 'No enabled models. Configure one in AI Models first.'} +
+ ) : ( + + )} +
+ {zh + ? '不选则自动使用已启用的模型' + : 'Leave blank to auto-use any enabled model'} +
+
+ ) + + return ( +
+
+ {/* Header */} +
+
+ {step > 0 && !config?.is_bound && ( + + )} +
+ +

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

+
+
+ +
+ + {/* Step Indicator */} +
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ {zh ? '加载中...' : 'Loading...'} +
+ ) : ( + <> + {/* Step 0: Create bot via BotFather */} + {step === 0 && ( +
+
+
+ 🤖 +
+
+ {zh ? '第一步:在 Telegram 创建你的 Bot' : 'Step 1: Create your Bot in Telegram'} +
+
+
1. {zh ? '打开 Telegram,搜索' : 'Open Telegram, search for'} @BotFather
+
2. {zh ? '发送' : 'Send'} /newbot {zh ? '命令' : 'command'}
+
3. {zh ? '按提示输入 Bot 名称和用户名' : 'Follow prompts to set bot name and username'}
+
4. {zh ? 'BotFather 会返回一个 Token,复制它' : 'BotFather will return a Token, copy it'}
+
+
+
+
+ + + + {zh ? '打开 @BotFather' : 'Open @BotFather'} + + +
+ + setToken(e.target.value)} + placeholder="123456789:ABCdefGHIjklmNOPQRstuvwxYZ" + className="w-full px-4 py-3 rounded-xl font-mono text-sm" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + /> +
+ {zh ? 'Token 格式:数字:字母数字串,如 123456789:ABCdef...' : 'Format: numbers:alphanumeric, e.g. 123456789:ABCdef...'} +
+
+ + + + +
+ )} + + {/* Step 1: Send /start to activate */} + {step === 1 && ( +
+
+
+ 📱 +
+
+ {zh ? '第二步:向你的 Bot 发送 /start' : 'Step 2: Send /start to your Bot'} +
+
+
1. {zh ? '在 Telegram 中搜索你刚创建的 Bot' : 'Search for your newly created Bot in Telegram'}
+
2. {zh ? '点击 Start 或发送' : 'Click Start or send'} /start
+
3. {zh ? 'Bot 会自动绑定到你的账号' : 'Bot will automatically bind to your account'}
+
+
+
+
+ + {config?.token_masked && ( +
+
+
+
+ {zh ? '当前 Token' : 'Current Token'} +
+
+ {config.token_masked} +
+
+
+ )} + +
+
+ {zh + ? '⏳ 等待你发送 /start... 发送后刷新页面查看状态' + : '⏳ Waiting for you to send /start... Refresh page after sending'} +
+
+ +
+ + +
+
+ )} + + {/* Step 2: Bound & active */} + {step === 2 && ( +
+
+
🎉
+
+ {zh ? 'Telegram Bot 已绑定!' : 'Telegram Bot is Active!'} +
+
+ {zh + ? '你现在可以通过 Telegram 用自然语言控制交易系统' + : 'You can now control the trading system via natural language in Telegram'} +
+
+ + {config?.token_masked && ( +
+
+
+
+ {zh ? 'Bot Token' : 'Bot Token'} +
+
+ {config.token_masked} +
+
+
+ )} + + {/* AI Model selector — works on active bot */} + { + setConfig((prev) => prev ? { ...prev, model_id: modelId } : prev) + }} + /> + + {/* What you can do */} +
+
+ {zh ? '支持的命令' : 'Supported Commands'} +
+ {[ + { cmd: '/help', desc: zh ? '查看所有命令' : 'Show all commands' }, + { cmd: zh ? '查看交易员状态' : 'Show trader status', desc: zh ? '自然语言查询' : 'Natural language' }, + { cmd: zh ? '启动/停止交易员' : 'Start/stop trader', desc: zh ? '自然语言控制' : 'Natural language control' }, + { cmd: zh ? '查看持仓' : 'View positions', desc: zh ? '实时持仓查询' : 'Real-time position query' }, + { cmd: zh ? '配置策略' : 'Configure strategy', desc: zh ? '修改交易策略' : 'Modify trading strategy' }, + ].map((item, i) => ( +
+ + {item.cmd} + + {item.desc} +
+ ))} +
+ +
+ + +
+
+ )} + + )} +
+
+
+ ) +} + +// BoundModelSelector — lets the user change the AI model when the bot is already active. +// It updates the model_id without requiring re-entry of the bot token. +function BoundModelSelector({ + zh, + models, + currentModelId, + onSaved, +}: { + zh: boolean + models: AIModel[] + currentModelId: string + onSaved: (modelId: string) => void +}) { + const [modelId, setModelId] = useState(currentModelId) + const [isSaving, setIsSaving] = useState(false) + + // Keep in sync if parent updates + useEffect(() => { setModelId(currentModelId) }, [currentModelId]) + + const handleSave = async () => { + setIsSaving(true) + try { + // POST /api/telegram/model — lightweight endpoint for model-only update + await api.updateTelegramModel(modelId) + onSaved(modelId) + toast.success(zh ? 'AI 模型已更新' : 'AI model updated') + } catch { + toast.error(zh ? '更新失败' : 'Update failed') + } finally { + setIsSaving(false) + } + } + + if (models.length === 0) return null + + return ( +
+ +
+ + +
+
+ ) +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 1877cb19..cf1c0649 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -339,8 +339,8 @@ export const translations = { selectTradingStrategy: 'Select Trading Strategy', useStrategy: 'Use Strategy', noStrategyManual: '-- No Strategy (Manual Configuration) --', - activeTag: ' (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: '-- 不使用策略(手动配置) --', - activeTag: ' (当前激活)', - 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) --', - activeTag: ' (Aktif)', - default: ' [Default]', + strategyActive: ' (Aktif)', + strategyDefault: ' [Default]', noStrategyHint: 'Belum ada strategi, buat di Strategy Studio terlebih dahulu', strategyDetails: 'Detail Strategi', activating: 'Mengaktifkan', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 84024439..fd3aa71f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -8,6 +8,7 @@ import type { TraderConfigData, AIModel, Exchange, + TelegramConfig, CreateTraderRequest, CreateExchangeRequest, UpdateModelConfigRequest, @@ -785,4 +786,26 @@ export const api = { if (!result.success) throw new Error('获取历史仓位失败') return result.data! }, + + // Telegram Bot API + async getTelegramConfig(): Promise { + const result = await httpClient.get(`${API_BASE}/telegram`) + if (!result.success) throw new Error('获取Telegram配置失败') + return result.data! + }, + + async updateTelegramConfig(token: string, modelId?: string): Promise { + const result = await httpClient.post(`${API_BASE}/telegram`, { bot_token: token, model_id: modelId ?? '' }) + if (!result.success) throw new Error('保存Telegram配置失败') + }, + + async unbindTelegram(): Promise { + const result = await httpClient.delete(`${API_BASE}/telegram/binding`) + if (!result.success) throw new Error('解绑Telegram失败') + }, + + async updateTelegramModel(modelId: string): Promise { + const result = await httpClient.post(`${API_BASE}/telegram/model`, { model_id: modelId }) + if (!result.success) throw new Error('更新Telegram模型失败') + }, } diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 335aacd0..54d138e0 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -1,6 +1,6 @@ export interface SystemConfig { - beta_mode: boolean - registration_enabled?: boolean + initialized: boolean + beta_mode?: boolean } let configPromise: Promise | null = null @@ -19,8 +19,11 @@ export function getSystemConfig(): Promise { cachedConfig = data return data }) - .finally(() => { - // Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later - }) return configPromise } + +/** Call after first-time setup completes so next check reflects initialized=true */ +export function invalidateSystemConfig() { + cachedConfig = null + configPromise = null +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..0ad9a875 --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,489 @@ +import { useState, useEffect } from 'react' +import { toast } from 'sonner' +import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../contexts/LanguageContext' +import { api } from '../lib/api' +import { ExchangeConfigModal } from '../components/traders/ExchangeConfigModal' +import { TelegramConfigModal } from '../components/traders/TelegramConfigModal' +import { ModelConfigModal } from '../components/AITradersPage' +import type { Exchange, AIModel } from '../types' + +type Tab = 'account' | 'models' | 'exchanges' | 'telegram' + +export function SettingsPage() { + const { user } = useAuth() + const { language } = useLanguage() + const [activeTab, setActiveTab] = useState('account') + + // Account state + const [newPassword, setNewPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [changingPassword, setChangingPassword] = useState(false) + + // AI Models state + const [configuredModels, setConfiguredModels] = useState([]) + const [supportedModels, setSupportedModels] = useState([]) + const [showModelModal, setShowModelModal] = useState(false) + const [editingModel, setEditingModel] = useState(null) + + // Exchanges state + const [exchanges, setExchanges] = useState([]) + const [showExchangeModal, setShowExchangeModal] = useState(false) + const [editingExchange, setEditingExchange] = useState(null) + + // Telegram state + const [showTelegramModal, setShowTelegramModal] = useState(false) + + // Fetch data when tabs are visited + useEffect(() => { + if (activeTab === 'models') { + Promise.all([api.getModelConfigs(), api.getSupportedModels()]) + .then(([configs, supported]) => { + setConfiguredModels(configs) + setSupportedModels(supported) + }) + .catch(() => toast.error('Failed to load AI models')) + } + if (activeTab === 'exchanges') { + api.getExchangeConfigs() + .then(setExchanges) + .catch(() => toast.error('Failed to load exchanges')) + } + }, [activeTab]) + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + if (newPassword.length < 8) { + toast.error('Password must be at least 8 characters') + return + } + setChangingPassword(true) + try { + const res = await fetch('/api/user/password', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token') || ''}`, + }, + body: JSON.stringify({ new_password: newPassword }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to update password') + } + toast.success('Password updated successfully') + setNewPassword('') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update password') + } finally { + setChangingPassword(false) + } + } + + const handleSaveModel = async ( + modelId: string, + apiKey: string, + customApiUrl?: string, + customModelName?: string + ) => { + try { + const existingModel = configuredModels.find((m) => m.id === modelId) + const modelTemplate = supportedModels.find((m) => m.id === modelId) + const modelToUpdate = existingModel || modelTemplate + if (!modelToUpdate) { toast.error('Model not found'); return } + + let updatedModels: AIModel[] + if (existingModel) { + updatedModels = configuredModels.map((m) => + m.id === modelId + ? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true } + : m + ) + } else { + updatedModels = [...configuredModels, { + ...modelToUpdate, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + }] + } + + const request = { + models: Object.fromEntries( + updatedModels.map((m) => [m.provider, { + enabled: m.enabled, + api_key: m.apiKey || '', + custom_api_url: m.customApiUrl || '', + custom_model_name: m.customModelName || '', + }]) + ), + } + await toast.promise(api.updateModelConfigs(request), { + loading: 'Saving model config...', + success: 'Model config saved', + error: 'Failed to save model config', + }) + const refreshed = await api.getModelConfigs() + setConfiguredModels(refreshed) + setShowModelModal(false) + setEditingModel(null) + } catch { + toast.error('Failed to save model config') + } + } + + const handleDeleteModel = async (modelId: string) => { + try { + const updatedModels = configuredModels.map((m) => + m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m + ) + const request = { + models: Object.fromEntries( + updatedModels.map((m) => [m.provider, { + enabled: m.enabled, + api_key: m.apiKey || '', + custom_api_url: m.customApiUrl || '', + custom_model_name: m.customModelName || '', + }]) + ), + } + await api.updateModelConfigs(request) + const refreshed = await api.getModelConfigs() + setConfiguredModels(refreshed) + setShowModelModal(false) + setEditingModel(null) + toast.success('Model config removed') + } catch { + toast.error('Failed to remove model config') + } + } + + const handleSaveExchange = async ( + exchangeId: string | null, + exchangeType: string, + accountName: string, + apiKey: string, + secretKey?: string, + passphrase?: string, + testnet?: boolean, + hyperliquidWalletAddr?: string, + asterUser?: string, + asterSigner?: string, + asterPrivateKey?: string, + lighterWalletAddr?: string, + lighterPrivateKey?: string, + lighterApiKeyPrivateKey?: string, + lighterApiKeyIndex?: number + ) => { + try { + if (exchangeId) { + const request = { + exchanges: { + [exchangeId]: { + enabled: true, + api_key: apiKey || '', + secret_key: secretKey || '', + passphrase: passphrase || '', + testnet: testnet || false, + hyperliquid_wallet_addr: hyperliquidWalletAddr || '', + aster_user: asterUser || '', + aster_signer: asterSigner || '', + aster_private_key: asterPrivateKey || '', + lighter_wallet_addr: lighterWalletAddr || '', + lighter_private_key: lighterPrivateKey || '', + lighter_api_key_private_key: lighterApiKeyPrivateKey || '', + lighter_api_key_index: lighterApiKeyIndex || 0, + }, + }, + } + await toast.promise(api.updateExchangeConfigsEncrypted(request), { + loading: 'Updating exchange config...', + success: 'Exchange config updated', + error: 'Failed to update exchange config', + }) + } else { + const createRequest = { + exchange_type: exchangeType, + account_name: accountName, + enabled: true, + api_key: apiKey || '', + secret_key: secretKey || '', + passphrase: passphrase || '', + testnet: testnet || false, + hyperliquid_wallet_addr: hyperliquidWalletAddr || '', + aster_user: asterUser || '', + aster_signer: asterSigner || '', + aster_private_key: asterPrivateKey || '', + lighter_wallet_addr: lighterWalletAddr || '', + lighter_private_key: lighterPrivateKey || '', + lighter_api_key_private_key: lighterApiKeyPrivateKey || '', + lighter_api_key_index: lighterApiKeyIndex || 0, + } + await toast.promise(api.createExchangeEncrypted(createRequest), { + loading: 'Creating exchange account...', + success: 'Exchange account created', + error: 'Failed to create exchange account', + }) + } + const refreshed = await api.getExchangeConfigs() + setExchanges(refreshed) + setShowExchangeModal(false) + setEditingExchange(null) + } catch { + toast.error('Failed to save exchange config') + } + } + + const handleDeleteExchange = async (exchangeId: string) => { + try { + await toast.promise(api.deleteExchange(exchangeId), { + loading: 'Deleting exchange account...', + success: 'Exchange account deleted', + error: 'Failed to delete exchange account', + }) + const refreshed = await api.getExchangeConfigs() + setExchanges(refreshed) + setShowExchangeModal(false) + setEditingExchange(null) + } catch { + toast.error('Failed to delete exchange account') + } + } + + const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ + { key: 'account', label: 'Account', icon: }, + { key: 'models', label: 'AI Models', icon: }, + { key: 'exchanges', label: 'Exchanges', icon: }, + { key: 'telegram', label: 'Telegram', icon: }, + ] + + return ( +
+
+

Settings

+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ + {/* Account Tab */} + {activeTab === 'account' && ( +
+
+

Email

+

{user?.email}

+
+ +
+

Change Password

+
+
+ +
+ setNewPassword(e.target.value)} + className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder="At least 8 characters" + required + /> + +
+
+ +
+
+
+ )} + + {/* AI Models Tab */} + {activeTab === 'models' && ( +
+
+

+ {configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured +

+ +
+ + {configuredModels.length === 0 ? ( +
+ No AI models configured yet +
+ ) : ( +
+ {configuredModels.map((model) => ( + + ))} +
+ )} +
+ )} + + {/* Exchanges Tab */} + {activeTab === 'exchanges' && ( +
+
+

+ {exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected +

+ +
+ + {exchanges.length === 0 ? ( +
+ No exchange accounts connected yet +
+ ) : ( +
+ {exchanges.map((exchange) => ( + + ))} +
+ )} +
+ )} + + {/* Telegram Tab */} + {activeTab === 'telegram' && ( +
+

+ Connect a Telegram bot to receive trading notifications and interact with your traders. +

+ +
+ )} +
+
+ + {/* AI Model Modal */} + {showModelModal && ( +
+ { setShowModelModal(false); setEditingModel(null) }} + language={language} + /> +
+ )} + + {/* Exchange Modal */} + {showExchangeModal && ( +
+ { setShowExchangeModal(false); setEditingExchange(null) }} + language={language} + /> +
+ )} + + {/* Telegram Modal */} + {showTelegramModal && ( +
+ setShowTelegramModal(false)} + language={language} + /> +
+ )} +
+ ) +} diff --git a/web/src/types.ts b/web/src/types.ts index 703dc611..6532d96f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -116,6 +116,13 @@ export interface AIModel { customModelName?: string } +export interface TelegramConfig { + token_masked: string // Masked token like "123456:ABC***XYZ" + is_bound: boolean // Whether a user has sent /start + bound_chat_id?: number // The bound chat ID (if any) + model_id?: string // AI model selected for Telegram replies +} + export interface Exchange { id: string // UUID (empty for supported exchange templates) exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"