mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 22:10:57 +08:00
Delete backtest/ engine (19 files), api/backtest.go, store/backtest.go, web backtest components (7 files), API client, types, docs, screenshot. Clean all backtest references from main.go, api/server.go, store/store.go, App.tsx, HeaderBar.tsx, LandingPage.tsx, translations, README and docs.
1219 lines
34 KiB
Markdown
1219 lines
34 KiB
Markdown
# 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)
|
||
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 截图服务)
|