Files
nofx/docs/plans/2026-03-06-telegram-bot.md
T
tinkle-community 9c5c976d9a feat: Claw402 x402 payment provider + Telegram agent + x402 refactoring (#1409)
* feat(telegram): add AI agent bot with streaming and account context

- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and  placeholder
- Account state injection at conversation start (models, exchanges,
  strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection

* fix(telegram): eliminate narration, add full-setup workflow and tests

- Rewrite NO NARRATION rule: response is EITHER api_call tag alone OR
  final text reply — no text before api_call under any circumstances
- Ban all narration patterns: 现在我将/好的/正在/I will/Let me etc.
- Add 'create strategy + create trader + start' full setup workflow
- Add 12 automated tests covering:
  - No narration leaking to user (5 narration variants tested)
  - api_call tag never leaks to user
  - Full setup workflow: POST strategy → verify → POST trader → start
  - Start existing trader workflow
  - Max iterations safety, tag stripping, parser edge cases

* refactor(agent): replace XML api_call with native function calling

Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.

Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
  format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
  define api_request tool with JSON Schema; implement tool-call loop
  with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
  replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
  objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
  TestToolCallIDPropagated; remove XML-specific tests

Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.

All 11 agent tests pass. mcp package tests pass.

* refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format

Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).

Changes:

mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why

mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
  now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up

mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
    * system prompt → top-level "system" field
    * tools: parameters → input_schema, no "type:function" wrapper
    * tool_choice "auto" → {"type":"auto"} object
    * assistant tool calls → content[{type:tool_use, id, name, input}]
    * role=tool results → role=user content[{type:tool_result,...}]
    * consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull

All mcp and agent tests pass.

* fix(telegram): fix claude client dispatch + strategy creation workflow

- telegram/bot.go: clientForProvider now returns NewClaudeClient() for
  'claude' provider (was incorrectly falling back to DeepSeekClient which
  uses OpenAI wire format, breaking Anthropic API calls)

- api/server.go: fix scan_interval_minutes schema default (3, not 60);
  POST /api/strategies now clearly states config is OPTIONAL with complete
  working defaults; POST /api/traders removes redundant GET workflow note

- telegram/agent/prompt.go: simplify strategy creation — just POST {name}
  without config (backend applies full working defaults automatically);
  only include config when user requests custom settings

* test(mcp): add ClaudeClient wire format tests

Tests cover all Anthropic-specific format conversions:
- system prompt lifted to top-level field
- tools use input_schema (not parameters)
- tool_choice is object {type:auto} not string
- assistant tool calls → content[{type:tool_use}]
- consecutive tool results merged into single user turn
- parseMCPResponseFull: text, tool_use, and error cases
- x-api-key header (not Authorization: Bearer)
- /messages endpoint URL

* fix(telegram): clientForProvider returns correct client for all 7 providers

Previously qwen/kimi/grok/gemini all fell back to DeepSeekClient.
Each provider now gets its own dedicated client with correct default
base URL and model. All 7 providers now fully supported:
openai, deepseek, claude, qwen, kimi, grok, gemini

* fix(telegram): newLLMClient uses bound user's model, not any user's model

GetAnyEnabled() searched across all users in DB — if user B has an
enabled model, bot could use their API key while acting as user A.

Now uses GetDefault(botUserID) which only looks up the bound user's
enabled model, matching the same user scope as all API calls.

* fix(auth): single-user deployment by default, no open registration

Registration logic redesigned:
- Empty DB (first-time setup): registration always open, no config needed
- After first user exists: registration closed by default
- Multi-user opt-in: set REGISTRATION_ENABLED=true + MAX_USERS=N in .env

Config defaults changed:
- RegistrationEnabled: true → false (closed after first user)
- MaxUsers: 10 → 1 (single-user deployment default)

This eliminates the confusion of multiple users appearing in a personal
deployment where Telegram is bound to a single admin account.

* feat(solo): beginner-friendly onboarding — smart setup guide + direct config commands

start.sh:
- Interactive Telegram Bot Token prompt on first run
- Token format validation (must match 12345:ABC... pattern)
- Friendly step-by-step startup instructions after launch

telegram/bot.go:
- /start now shows context-aware setup guide based on actual config state:
  - No AI model → explains how to configure, lists all providers
  - AI model OK but no exchange → guides to configure exchange via chat
  - All configured → full capabilities welcome message
- New: direct setup commands ('配置 deepseek sk-xxx') bypass LLM entirely
  so AI model can be configured even before any model exists (bootstrap fix)
- All messages now in Chinese (匹配用户语言)

telegram/agent/prompt.go:
- Added first-time setup detection section
- Agent told to never ask user to visit web UI — everything via chat

* feat(i18n): bilingual EN/ZH setup guide with language selection

store/telegram_config.go:
- Add Language field to TelegramConfig (persisted in DB)
- Add SetLanguage(lang) and GetLanguage() methods
- Default language: English (en)

telegram/bot.go:
- First /start triggers language selection (1=English, 2=中文)
- /lang command to change language at any time
- awaitingLang state machine handles language choice before any other input
- buildSetupGuide() now fully bilingual (EN/ZH), context-aware:
  Step 1: configure AI model (no model yet)
  Step 2: configure exchange (model OK, no exchange)
  Ready: show full capabilities
- tryHandleSetupCommand() bilingual: 'configure/配置 <provider> <key>'
- helpMessage(lang) fully bilingual
- All error/status messages bilingual

Default: English. isLangDefault() detects whether user has explicitly
chosen a language vs falling back to the 'en' default.

* fix(telegram): use Markdown rendering + simplify language selection condition

- sendMarkdownMsg() helper: sends with ParseMode=Markdown, falls back to plain text
- All formatted messages (langSelectionMsg, buildSetupGuide, helpMessage) now render
  bold text and code blocks correctly in Telegram
- Simplify /start language check: isLangDefault(st) alone is sufficient
  (lang == 'en' && isLangDefault was redundant — GetLanguage returns 'en' when empty)

* fix(start.sh): translate all user-facing text to English

Entire script was in Chinese. Now English-first throughout:
- startup banner, prompts, success/error messages
- setup_telegram(): English instructions and validation messages
- start(): English next-steps after launch
- stop/restart/clean/update/regenerate-keys/show_help: all English

* fix(telegram): remove 'default' user fallback — resolve user dynamically

- botUserID no longer captured once at startup (was 'default' if no user yet)
- resolveBotUser() reads first registered user from DB on demand:
  * called on every /start (handles: registered after bot launch)
  * called before every AI message (handles mid-session registration)
- If no user registered: clear English error 'No account found. Please register on the web UI first'
- start.sh: fix set_env_var appending without newline (token was concatenated to prev line)

* 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

* refactor: single-user web-based setup — replace env config with Settings UI

Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup

* feat: add blockRun (x402 USDC) support to all AI model consumers

- telegram/bot.go: add blockrun-base, blockrun-sol, minimax to
  clientForProvider; fix newLLMClient to prefer TelegramConfig.ModelID
  over GetDefault; log USDC payment provider usage
- debate/engine.go: add blockrun-base, blockrun-sol to InitializeClients
- api/strategy.go: add blockrun-base, blockrun-sol to runRealAITest
- backtest/ai_client.go: add blockrun-base, blockrun-sol to configureMCPClient

* feat: add Claw402 (claw402.ai) x402 USDC payment provider

Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC).
Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.)
with per-model endpoint routing.

- mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow
- mcp/blockrun_base.go: extract shared signX402Payment() for reuse
- Register "claw402" provider in all 6 consumer switch statements:
  api/server.go, api/strategy.go, trader/auto_trader.go,
  telegram/bot.go, debate/engine.go, backtest/ai_client.go

* feat: redesign Claw402 model config UI — friendly wallet setup, USDC guide, official logo, nginx no-cache for index.html

* refactor: centralize x402 payment flow into shared mcp/x402.go

Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/
setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate
shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove
duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402
body before retry, log Payment-Response tx hash, check Payment-Required before
X-Payment-Required.

* fix: stop PR template bot from overwriting user-written descriptions

The pr-template-suggester workflow was triggered on opened/edited/synchronize
events and forcefully replaced the PR body with a template when body < 100 chars.
This caused user-written descriptions to be overwritten.

Replace with a lightweight labeler (OpenClaw-style) that:
- Only adds labels (backend/frontend/docs, size: XS/S/M/L/XL)
- Never modifies the PR body
- Simplified unified PR template at .github/pull_request_template.md

* chore: simplify PR template (OpenClaw-style)
2026-03-11 16:01:42 +08:00

34 KiB
Raw Blame History

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.IDstring,不是 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 末尾加两个字段

// 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 的模式,加:

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: 构建验证

cd /Users/yida/gopro/open-nofx && go build ./...

Expected: 无错误

Step 4: Commit

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: 创建文件

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 验证

cd /Users/yida/gopro/open-nofx && go build ./telegram/...

Expected: 只可能有 store 方法名不匹配的错误,逐一修正即可。

Step 4: Commit

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: 创建文件

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 验证

cd /Users/yida/gopro/open-nofx && go build ./telegram/...

Step 3: Commit

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: 创建文件

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 验证

cd /Users/yida/gopro/open-nofx && go build ./telegram/...

Step 3: Commit

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: 创建文件

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 验证

cd /Users/yida/gopro/open-nofx && go build ./telegram/...

Step 3: Commit

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: 创建文件

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 不是 %dAIModel 也没有示例中的 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 验证

cd /Users/yida/gopro/open-nofx && go build ./telegram/...

Step 3: Commit

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: 创建文件

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 验证

cd /Users/yida/gopro/open-nofx && go build ./telegram/...

Step 3: Commit

git add telegram/bot.go
git commit -m "feat(telegram): add Telegram bot entry point with access control"

Task 8: 接入 main.go3 行改动)

Files:

  • Modify: main.go

Step 1: 加 import

在 main.go 的 import 块加:

"nofx/telegram"

Step 2: 在 API Server 启动之后加 3 行

找到这段代码:

// Start API server
server := api.NewServer(...)
go func() { ... }()

在其后加:

// Start Telegram bot (if configured)
go telegram.Start(cfg, st, traderManager)
logger.Info("🤖 Telegram bot goroutine started")

Step 3: 完整构建

cd /Users/yida/gopro/open-nofx && go build -o nofx .

Expected: 成功编译,无错误

Step 4: Commit

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 末尾加

# 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

git add .env.example
git commit -m "docs: add Telegram bot configuration to .env.example"

Task 10: 手动集成测试

Step 1: 配置环境变量

export TELEGRAM_BOT_TOKEN=你的bot_token
export TELEGRAM_ADMIN_CHAT_ID=你的chat_id

Step 2: 启动 NOFX

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 截图服务)