refactor(telegram): clean onboarding — web UI for setup, Telegram for operations

- /start shows clean status: 'setup required → open web UI' or 'ready → examples'
- Removed tryHandleSetupCommand (no more CLI-style 'configure deepseek sk-xxx')
- Removed automatic language selection on /start (use /lang anytime instead)
- newLLMClient returns nil when no model → clear guard, not fallback
- statusMsg() replaces buildSetupGuide(): two states only (missing config / ready)
- Bot is now purely an operations interface; config lives in the web UI
This commit is contained in:
tinkle-community
2026-03-08 20:16:58 +08:00
parent aebca4b16c
commit 9a3017af6d

View File

@@ -1,11 +1,6 @@
package telegram package telegram
import ( import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/api" "nofx/api"
"nofx/config" "nofx/config"
"nofx/logger" "nofx/logger"
@@ -23,25 +18,20 @@ import (
// Start initializes and runs the Telegram bot in a blocking supervisor loop. // 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 // 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. // with the latest token (re-read from DB or env). Must be called as a goroutine from main.go.
// Deployment note: uses long-polling (not webhook) — safe for private networks,
// no inbound ports required.
func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) { func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
for { for {
token := resolveToken(cfg, st) token := resolveToken(cfg, st)
if token == "" { if token == "" {
logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...") logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...")
// Block until a reload signal arrives, then re-check for a token.
<-reloadCh <-reloadCh
continue continue
} }
stopped := runBot(token, cfg, st) stopped := runBot(token, cfg, st)
if !stopped { if !stopped {
// Bot exited with an unrecoverable error; do not restart automatically.
return return
} }
// Bot was stopped cleanly. Wait for a reload signal before restarting.
select { select {
case <-reloadCh: case <-reloadCh:
logger.Info("Reloading Telegram bot with new token...") logger.Info("Reloading Telegram bot with new token...")
@@ -58,20 +48,16 @@ func resolveToken(cfg *config.Config, st *store.Store) string {
return cfg.TelegramBotToken return cfg.TelegramBotToken
} }
// runBot runs the bot until StopReceivingUpdates is called (clean stop → true) // runBot runs the bot until the updates channel closes (clean stop → true) or a fatal error (false).
// or a fatal error occurs (false).
func runBot(token string, cfg *config.Config, st *store.Store) bool { func runBot(token string, cfg *config.Config, st *store.Store) bool {
bot, err := tgbotapi.NewBotAPI(token) bot, err := tgbotapi.NewBotAPI(token)
if err != nil { if err != nil {
logger.Errorf("Telegram bot failed to start: %v", err) logger.Errorf("Telegram bot failed to start: %v", err)
return false return false
} }
logger.Infof("Telegram bot @%s started (long-polling mode)", bot.Self.UserName) logger.Infof("Telegram bot @%s started", bot.Self.UserName)
// Determine allowed chat ID: // Allowed chat ID: env override → DB-stored binding → 0 (unbound, first /start will bind).
// Priority 1: env var TELEGRAM_ADMIN_CHAT_ID (explicit)
// Priority 2: DB-stored bound chat ID (set by /start)
// Priority 3: 0 = no binding yet, first /start will bind
allowedChatID := cfg.TelegramAdminChatID allowedChatID := cfg.TelegramAdminChatID
if allowedChatID == 0 { if allowedChatID == 0 {
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 { if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
@@ -79,17 +65,13 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
} }
} }
// botUserID/botToken/agents are resolved lazily on the first message. // botUserID / botToken / agents are resolved lazily and refresh when user registers.
// They refresh automatically when the user registers on the web UI.
var ( var (
botUserID string botUserID string
botToken string botToken string
agents *agent.Manager agents *agent.Manager
) )
// resolveBotUser reads the first registered user from DB.
// Returns true and refreshes botUserID/botToken/agents when the user changes.
// Returns false if no user is registered yet (no "default" fallback).
resolveBotUser := func() bool { resolveBotUser := func() bool {
ids, err := st.User().GetAllIDs() ids, err := st.User().GetAllIDs()
if err != nil || len(ids) == 0 { if err != nil || len(ids) == 0 {
@@ -97,38 +79,34 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
} }
newID := ids[0] newID := ids[0]
if newID == botUserID { if newID == botUserID {
return true // already up-to-date return true
} }
// User changed (or first resolve) — regenerate JWT and agent manager. newToken, err := agent.GenerateBotToken(newID)
newToken, tokenErr := agent.GenerateBotToken(newID) if err != nil {
if tokenErr != nil { logger.Errorf("Failed to generate bot JWT for user %s: %v", newID, err)
logger.Errorf("Failed to generate bot JWT for user %s: %v", newID, tokenErr)
return false return false
} }
prevID := botUserID prev := botUserID
botUserID = newID botUserID = newID
botToken = newToken botToken = newToken
agents = agent.NewManager(cfg.APIServerPort, botToken, botUserID, agents = agent.NewManager(cfg.APIServerPort, botToken, botUserID,
func() mcp.AIClient { return newLLMClient(st, botUserID) }, func() mcp.AIClient { return newLLMClient(st, botUserID) },
api.GetAPIDocs(), api.GetAPIDocs(),
) )
if prevID == "" { if prev == "" {
logger.Infof("Bot: resolved user %s", botUserID) logger.Infof("Bot: resolved user %s", botUserID)
} else { } else {
logger.Infof("Bot: user changed %s → %s, agent manager refreshed", prevID, botUserID) logger.Infof("Bot: user changed %s → %s", prev, botUserID)
} }
return true return true
} }
// Initial resolve — may fail if no user registered yet, which is fine.
resolveBotUser() resolveBotUser()
u := tgbotapi.NewUpdate(0) u := tgbotapi.NewUpdate(0)
u.Timeout = 60 u.Timeout = 60
updates := bot.GetUpdatesChan(u) updates := bot.GetUpdatesChan(u)
// awaitingLang is true when the bot is waiting for the user to pick a language (1 or 2). // awaitingLang is set only when the user explicitly runs /lang.
// It resets to false once a valid choice is received or /lang is re-issued.
awaitingLang := false awaitingLang := false
for update := range updates { for update := range updates {
@@ -136,28 +114,26 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
continue continue
} }
chatID := update.Message.Chat.ID chatID := update.Message.Chat.ID
text := update.Message.Text text := strings.TrimSpace(update.Message.Text)
// Language selection state: user must choose "1" or "2" after /start on first use. // ── Language selection (triggered only by /lang) ──────────────────────
// awaitingLang is true only until the user makes a choice (or we fall back to "en").
if awaitingLang && chatID == allowedChatID { if awaitingLang && chatID == allowedChatID {
lang := parseLangChoice(text) if lang := parseLangChoice(text); lang != "" {
if lang != "" {
awaitingLang = false awaitingLang = false
st.TelegramConfig().SetLanguage(lang) //nolint:errcheck st.TelegramConfig().SetLanguage(lang) //nolint:errcheck
sendMarkdownMsg(bot, chatID, buildSetupGuide(st, botUserID, cfg.APIServerPort, botToken, lang)) sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
} else { } else {
sendMarkdownMsg(bot, chatID, langSelectionMsg()) sendMarkdownMsg(bot, chatID, langMenuMsg())
} }
continue continue
} }
// Handle /start: auto-bind or language selection / welcome // ── /start ────────────────────────────────────────────────────────────
if text == "/start" { if text == "/start" {
// Re-resolve user on every /start (handles: registered after bot launch).
resolveBotUser() resolveBotUser()
if botUserID == "" { if botUserID == "" {
sendMsg(bot, chatID, "No account found. Please register on the web UI first, then send /start.") sendMsg(bot, chatID,
"No account found.\nOpen the web dashboard to register, then send /start.")
continue continue
} }
if allowedChatID == 0 { if allowedChatID == 0 {
@@ -170,37 +146,31 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
allowedChatID = chatID allowedChatID = chatID
logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID) logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID)
} else if chatID != allowedChatID { } else if chatID != allowedChatID {
sendMsg(bot, chatID, "This bot is already bound to another user.") sendMsg(bot, chatID, "This bot is already bound to another account.")
continue continue
} else { } else {
agents.Reset(chatID) agents.Reset(chatID)
} }
// Show language selection if user has never chosen (Language == ""); go straight to guide otherwise. lang := st.TelegramConfig().GetLanguage()
if isLangDefault(st) { sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
awaitingLang = true
sendMarkdownMsg(bot, chatID, langSelectionMsg())
} else {
lang := st.TelegramConfig().GetLanguage()
sendMarkdownMsg(bot, chatID, buildSetupGuide(st, botUserID, cfg.APIServerPort, botToken, lang))
}
continue continue
} }
// Handle /lang: change language at any time // ── /lang ─────────────────────────────────────────────────────────────
if text == "/lang" { if text == "/lang" {
awaitingLang = true awaitingLang = true
sendMarkdownMsg(bot, chatID, langSelectionMsg()) sendMarkdownMsg(bot, chatID, langMenuMsg())
continue continue
} }
// Handle /help // ── /help ─────────────────────────────────────────────────────────────
if text == "/help" { if text == "/help" {
lang := st.TelegramConfig().GetLanguage() lang := st.TelegramConfig().GetLanguage()
sendMarkdownMsg(bot, chatID, helpMessage(lang)) sendMarkdownMsg(bot, chatID, helpMsg(lang))
continue continue
} }
// Access control // ── Access control ────────────────────────────────────────────────────
if allowedChatID != 0 && chatID != allowedChatID { if allowedChatID != 0 && chatID != allowedChatID {
sendMsg(bot, chatID, "Unauthorized.") sendMsg(bot, chatID, "Unauthorized.")
continue continue
@@ -213,38 +183,29 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
continue continue
} }
// Refresh user on every message (handles registration that happened mid-session). // ── Refresh user before every AI call ────────────────────────────────
resolveBotUser() resolveBotUser()
if botUserID == "" { if botUserID == "" {
sendMsg(bot, chatID, "No account found. Please register on the web UI first.") sendMsg(bot, chatID, "No account found. Open the web dashboard to register.")
continue continue
} }
// Direct setup commands (no LLM needed): "configure deepseek sk-xxx" / "配置 deepseek sk-xxx"
lang := st.TelegramConfig().GetLanguage() lang := st.TelegramConfig().GetLanguage()
if reply, handled := tryHandleSetupCommand(text, cfg.APIServerPort, botToken, st, botUserID, lang); handled {
sendMarkdownMsg(bot, chatID, reply)
continue
}
// Guard: if no AI model configured, show setup guide instead of failing. // ── Guard: show status if not ready for trading ───────────────────────
if newLLMClient(st, botUserID) == nil { if newLLMClient(st, botUserID) == nil {
sendMarkdownMsg(bot, chatID, buildSetupGuide(st, botUserID, cfg.APIServerPort, botToken, lang)) sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
continue continue
} }
// Send a placeholder immediately, then stream-edit as reply arrives. // ── AI agent ─────────────────────────────────────────────────────────
go func(chatID int64, text string) { go func(chatID int64, text string) {
// Send ⏳ placeholder so the user sees an instant response.
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳")) sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
placeholderID := 0 placeholderID := 0
if err == nil { if err == nil {
placeholderID = sent.MessageID placeholderID = sent.MessageID
} }
// Rate-limited edit helper: edits the placeholder at most once per second.
// Exception: "⏳" thinking-indicator resets always go through immediately
// so the user always sees the state change between agent iterations.
var ( var (
mu sync.Mutex mu sync.Mutex
lastEdit time.Time lastEdit time.Time
@@ -255,8 +216,7 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
} }
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
isThinking := accumulated == "⏳" if accumulated != "⏳" && time.Since(lastEdit) < time.Second {
if !isThinking && time.Since(lastEdit) < time.Second {
return return
} }
lastEdit = time.Now() lastEdit = time.Now()
@@ -266,7 +226,6 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
reply := agents.Run(chatID, text, onChunk) reply := agents.Run(chatID, text, onChunk)
// Final edit: use Markdown, fall back to plain text on parse error.
if placeholderID != 0 { if placeholderID != 0 {
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply) edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
edit.ParseMode = "Markdown" edit.ParseMode = "Markdown"
@@ -285,46 +244,37 @@ func runBot(token string, cfg *config.Config, st *store.Store) bool {
}(chatID, text) }(chatID, text)
} }
// updates channel was closed — bot stopped cleanly
return true return true
} }
// ── Helpers ───────────────────────────────────────────────────────────────────
func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) { func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
bot.Send(msg) //nolint:errcheck bot.Send(msg) //nolint:errcheck
} }
// sendMarkdownMsg sends a message with Markdown formatting; falls back to plain text on parse error.
func sendMarkdownMsg(bot *tgbotapi.BotAPI, chatID int64, text string) { func sendMarkdownMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = "Markdown" msg.ParseMode = "Markdown"
if _, err := bot.Send(msg); err != nil { if _, err := bot.Send(msg); err != nil {
// Markdown rejected (e.g. unescaped special chars) — fall back to plain text.
plain := tgbotapi.NewMessage(chatID, text) plain := tgbotapi.NewMessage(chatID, text)
bot.Send(plain) //nolint:errcheck bot.Send(plain) //nolint:errcheck
} }
} }
// newLLMClient builds an LLM client for the agent using the bound user's enabled model. // ── LLM client ───────────────────────────────────────────────────────────────
// Priority: bound user's DB model > environment variables.
// Uses provider-specific constructors to ensure correct default base URLs and models.
func newLLMClient(st *store.Store, userID string) mcp.AIClient { func newLLMClient(st *store.Store, userID string) mcp.AIClient {
// 1. Try the bound user's enabled model from DB (configured via Web UI)
if model, err := st.AIModel().GetDefault(userID); err == nil { if model, err := st.AIModel().GetDefault(userID); err == nil {
apiKey := string(model.APIKey) apiKey := string(model.APIKey)
if apiKey != "" { if apiKey != "" {
client := clientForProvider(model.Provider) client := clientForProvider(model.Provider)
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName) client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
logger.Infof("Telegram agent: provider=%s user_id=%s model=%q url=%q", logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID)
model.Provider, model.UserID, model.CustomModelName, model.CustomAPIURL)
return client return client
} }
logger.Warnf("Telegram: DB model found (provider=%s) but API key is empty after decryption", model.Provider)
} else {
logger.Warnf("Telegram: no enabled model for user %s (%v), trying env vars", userID, err)
} }
// 2. Fall back to environment variables
for _, pair := range []struct{ provider, key, url string }{ for _, pair := range []struct{ provider, key, url string }{
{"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL}, {"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL},
{"openai", os.Getenv("OPENAI_API_KEY"), ""}, {"openai", os.Getenv("OPENAI_API_KEY"), ""},
@@ -333,17 +283,12 @@ func newLLMClient(st *store.Store, userID string) mcp.AIClient {
if pair.key != "" { if pair.key != "" {
client := clientForProvider(pair.provider) client := clientForProvider(pair.provider)
client.SetAPIKey(pair.key, pair.url, "") client.SetAPIKey(pair.key, pair.url, "")
logger.Infof("Telegram agent: using %s from env var", pair.provider)
return client return client
} }
} }
return nil
logger.Warn("Telegram: no AI key found in DB or env — agent will fail. Configure a model in the Web UI.")
return mcp.NewDeepSeekClient() // return a typed client so caller gets a clear API error
} }
// clientForProvider returns the appropriate provider-specific client.
// Each constructor sets correct default base URL and model for that provider.
func clientForProvider(provider string) mcp.AIClient { func clientForProvider(provider string) mcp.AIClient {
switch provider { switch provider {
case "openai": case "openai":
@@ -361,287 +306,137 @@ func clientForProvider(provider string) mcp.AIClient {
case "gemini": case "gemini":
return mcp.NewGeminiClient() return mcp.NewGeminiClient()
default: default:
// Unknown/custom provider: fall back to OpenAI-compatible format.
return mcp.NewDeepSeekClient() return mcp.NewDeepSeekClient()
} }
} }
// ── Language selection ──────────────────────────────────────────────────────── // ── Status message ────────────────────────────────────────────────────────────
// langSelectionMsg is always bilingual so it works before a language is chosen. // statusMsg is the single entry-point message shown after /start.
func langSelectionMsg() string { // It checks what's configured and shows either a setup prompt or the ready state.
return `🌐 *Please select your language / 请选择语言* func statusMsg(st *store.Store, userID string, apiPort int, lang string) string {
webURL := "http://localhost:3000"
1⃣ English // Determine what's missing.
2⃣ 中文 hasModel := false
if _, err := st.AIModel().GetDefault(userID); err == nil {
hasModel = true
}
Reply with 1 or 2 / 发送 1 或 2` 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"
} }
// parseLangChoice returns "en", "zh", or "" (unrecognised).
func parseLangChoice(text string) string { func parseLangChoice(text string) string {
switch strings.TrimSpace(text) { switch strings.TrimSpace(text) {
case "1", "English", "english", "en", "EN": case "1", "en", "EN", "English", "english":
return "en" return "en"
case "2", "中文", "zh", "ZH", "chinese", "Chinese": case "2", "zh", "ZH", "中文", "chinese", "Chinese":
return "zh" return "zh"
} }
return "" return ""
} }
// isLangDefault returns true if the user has never explicitly picked a language // ── Help ──────────────────────────────────────────────────────────────────────
// (i.e. the stored value is empty — the "en" default from GetLanguage() is a fallback).
func isLangDefault(st *store.Store) bool {
cfg, err := st.TelegramConfig().Get()
if err != nil {
return true
}
return cfg.Language == ""
}
// ── Setup guide ─────────────────────────────────────────────────────────────── func helpMsg(lang string) string {
// buildSetupGuide returns a context-aware onboarding message in the chosen language.
func buildSetupGuide(st *store.Store, userID string, apiPort int, botToken string, lang string) string {
// Step 1: AI model configured?
if _, err := st.AIModel().GetDefault(userID); err != nil {
if lang == "zh" {
return `🤖 *NOFX 个人 AI 交易助手*
欢迎!开始前需要配置 AI 模型。
*第一步:配置 AI 模型*
发送以下格式(选一个你有账号的服务商):
` + "```" + `
配置 deepseek 你的API-Key
配置 openai 你的API-Key
配置 claude 你的API-Key
配置 qwen 你的API-Key
配置 kimi 你的API-Key
配置 grok 你的API-Key
配置 gemini 你的API-Key
` + "```" + `
*推荐*DeepSeek 价格低、效果好
获取 Keyhttps://platform.deepseek.com/api_keys
发送 /lang 切换语言`
}
return `🤖 *NOFX Personal AI Trading Bot*
Welcome! You need to configure an AI model before trading.
*Step 1: Configure AI Model*
Send a message in this format (pick a provider you have access to):
` + "```" + `
configure deepseek your-api-key
configure openai your-api-key
configure claude your-api-key
configure qwen your-api-key
configure kimi your-api-key
configure grok your-api-key
configure gemini your-api-key
` + "```" + `
*Recommended*: DeepSeek — low cost, great performance
Get your key: https://platform.deepseek.com/api_keys
Send /lang to change language`
}
// Step 2: Exchange configured?
exchanges, _ := st.Exchange().List(userID)
hasEnabled := false
for _, e := range exchanges {
if e.Enabled {
hasEnabled = true
break
}
}
if !hasEnabled {
if lang == "zh" {
return `✅ AI 模型已配置!
*第二步:配置交易所*
直接发消息告诉我交易所信息,例如:
_"帮我配置 OKXAPI Key 是 xxxSecret 是 xxxPassphrase 是 xxx"_
_"帮我配置 BinanceAPI Key 是 xxxSecret Key 是 xxx"_
_"帮我配置 BybitAPI Key 是 xxxSecret Key 是 xxx"_
去交易所官网 → 账户设置 → API 管理 → 新建(需开启合约交易权限)`
}
return `✅ AI model configured!
*Step 2: Configure Exchange*
Just tell me your exchange credentials, for example:
_"Configure OKX, API Key is xxx, Secret is xxx, Passphrase is xxx"_
_"Configure Binance, API Key is xxx, Secret Key is xxx"_
_"Configure Bybit, API Key is xxx, Secret Key is xxx"_
Go to your exchange → Account → API Management → Create Key (enable futures/contract trading)`
}
// All configured
if lang == "zh" {
return `✅ *NOFX 交易助手已就绪*
直接发消息告诉我你要做什么:
*查询*
_"查看我的持仓"_、_"查看账户余额"_
*创建并启动交易*
_"帮我创建一个 BTC 趋势策略并跑起来"_
_"创建保守型策略,只交易 BTC 和 ETH"_
*控制*
_"启动交易员"_、_"暂停交易员"_
/start 重置对话 | /help 帮助 | /lang 切换语言`
}
return `✅ *NOFX Trading Bot Ready*
Just tell me what you want to do:
*Query*
_"Show my positions"_, _"Show account balance"_
*Create & Start Trading*
_"Create a BTC trend strategy and start it"_
_"Create a conservative strategy, BTC and ETH only"_
*Control*
_"Start trader"_, _"Stop trader"_
/start reset session | /help | /lang change language`
}
// ── Direct setup commands (no LLM required) ───────────────────────────────────
// tryHandleSetupCommand intercepts "configure/配置 <provider> <key>" messages
// and calls PUT /api/models directly — no LLM needed, works during bootstrapping.
func tryHandleSetupCommand(text string, apiPort int, botToken string, st *store.Store, userID string, lang string) (string, bool) {
text = strings.TrimSpace(text)
lower := strings.ToLower(text)
if !strings.HasPrefix(text, "配置 ") && !strings.HasPrefix(lower, "configure ") {
return "", false
}
parts := strings.Fields(text)
if len(parts) < 3 {
if lang == "zh" {
return "格式:配置 <服务商> <API-Key>\n例如配置 deepseek sk-xxxxxxxxx", true
}
return "Format: configure <provider> <api-key>\nExample: configure deepseek sk-xxxxxxxxx", true
}
provider := strings.ToLower(parts[1])
apiKey := parts[2]
validProviders := map[string]bool{
"openai": true, "deepseek": true, "claude": true,
"qwen": true, "kimi": true, "grok": true, "gemini": true,
}
if !validProviders[provider] {
if lang == "zh" {
return fmt.Sprintf("不支持的服务商:%s\n支持openai / deepseek / claude / qwen / kimi / grok / gemini", provider), true
}
return fmt.Sprintf("Unknown provider: %s\nSupported: openai / deepseek / claude / qwen / kimi / grok / gemini", provider), true
}
body, _ := json.Marshal(map[string]any{
"models": map[string]any{
provider: map[string]any{"enabled": true, "api_key": apiKey},
},
})
req, err := http.NewRequest("PUT", fmt.Sprintf("http://127.0.0.1:%d/api/models", apiPort), bytes.NewReader(body))
if err != nil {
if lang == "zh" {
return "配置请求失败,请稍后重试", true
}
return "Failed to create request, please try again", true
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+botToken)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
if lang == "zh" {
return "无法连接服务,请确认服务正常运行", true
}
return "Cannot reach service, please check it is running", true
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Sprintf("Error %d: %s", resp.StatusCode, string(respBody)), true
}
logger.Infof("Bot: setup command configured provider=%s", provider)
if lang == "zh" {
return fmt.Sprintf("✅ %s 配置成功!\n\n发送 /start 查看下一步", provider), true
}
return fmt.Sprintf("✅ %s configured successfully!\n\nSend /start to see the next step", provider), true
}
// ── Help message ──────────────────────────────────────────────────────────────
func helpMessage(lang string) string {
if lang == "zh" { if lang == "zh" {
return `*NOFX 使用指南* return `*NOFX 使用指南*
*查询* *查询*
- "查看我的持仓" "查看我的持仓"
- "查看账户余额" "账户余额多少"
- "列出我的交易员" "列出我的交易员"
*创建 & 启动*
• "帮我创建 BTC 趋势策略并跑起来"
• "保守型策略,只交易 BTC 和 ETH"
*控制* *控制*
- "启动交易员" "启动交易员"
- "暂停 xxx 交易员" "暂停交易员"
• "停止所有交易"
*创建策略*
- "帮我创建 BTC 趋势策略并跑起来"
- "创建保守型策略BTC ETH止损 8%"
*直接配置(不需要 AI*
- 配置 deepseek sk-xxxx
- 配置 openai sk-xxxx
*命令* *命令*
/start - 重置对话 / 查看配置状态 /start — 刷新状态
/lang - 切换语言 /lang 切换语言
/help - 显示此帮助` /help 帮助`
} }
return `*NOFX Help* return `*NOFX Help*
*Query* *Query*
- "Show my positions" "Show my positions"
- "Show account balance" "What's my balance?"
- "List my traders" "List my traders"
*Create & start*
• "Create a BTC trend strategy and start it"
• "Conservative strategy, BTC and ETH only"
*Control* *Control*
- "Start trader" "Start trader"
- "Stop trader [name]" "Stop trader"
• "Stop all trading"
*Create strategy*
- "Create a BTC trend strategy and start it"
- "Create a conservative strategy, BTC and ETH, 8% stop loss"
*Direct setup (no AI needed)*
- configure deepseek sk-xxxx
- configure openai sk-xxxx
*Commands* *Commands*
/start - Reset session / check setup status /start — refresh status
/lang - Change language /lang — change language
/help - Show this help` /help — show this`
} }