mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
467 lines
18 KiB
Go
467 lines
18 KiB
Go
package agent
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"nofx/security"
|
||
"nofx/store"
|
||
)
|
||
|
||
type ConfigValidationResult struct {
|
||
Warnings []string
|
||
}
|
||
|
||
type ConfigValidator interface {
|
||
Validate() error
|
||
}
|
||
|
||
var (
|
||
openAIAPIKeyPattern = regexp.MustCompile(`^sk-[A-Za-z0-9\-_]{4,}$`)
|
||
genericAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_\-]{8,}$`)
|
||
hexCredentialPattern = regexp.MustCompile(`^(0x)?[A-Fa-f0-9]{16,}$`)
|
||
supportedModelProvider = map[string]struct{}{
|
||
"openai": {}, "deepseek": {}, "claude": {}, "gemini": {}, "qwen": {}, "kimi": {}, "grok": {}, "minimax": {}, "claw402": {}, "blockrun-base": {}, "blockrun-sol": {},
|
||
}
|
||
)
|
||
|
||
const (
|
||
manualTraderScanIntervalMin = 3
|
||
manualTraderScanIntervalMax = 60
|
||
manualTraderInitialBalance = 100.0
|
||
manualLighterAPIKeyIndexMin = 0
|
||
manualLighterAPIKeyIndexMax = 255
|
||
)
|
||
|
||
type modelConfigValidator struct {
|
||
provider string
|
||
enabled bool
|
||
apiKey string
|
||
customAPIURL string
|
||
customModelName string
|
||
modelID string
|
||
}
|
||
|
||
func (v modelConfigValidator) Validate() error {
|
||
provider := strings.ToLower(strings.TrimSpace(v.provider))
|
||
if provider == "" {
|
||
return fmt.Errorf("provider is required")
|
||
}
|
||
if _, ok := supportedModelProvider[provider]; !ok {
|
||
return fmt.Errorf("unsupported provider: %s", provider)
|
||
}
|
||
if trimmed := strings.TrimSpace(v.customAPIURL); trimmed != "" {
|
||
if err := security.ValidateURL(strings.TrimSuffix(trimmed, "#")); err != nil {
|
||
return fmt.Errorf("invalid custom_api_url: %w", err)
|
||
}
|
||
}
|
||
if v.enabled && !modelConfigUsable(provider, v.modelID, strings.TrimSpace(v.apiKey), strings.TrimSpace(v.customAPIURL), strings.TrimSpace(v.customModelName)) {
|
||
return fmt.Errorf("cannot enable model config before a usable API key, URL, and model are configured")
|
||
}
|
||
if provider == "openai" && strings.TrimSpace(v.apiKey) != "" && !openAIAPIKeyPattern.MatchString(strings.TrimSpace(v.apiKey)) {
|
||
return fmt.Errorf("OpenAI API Key format looks invalid")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type exchangeConfigValidator struct {
|
||
exchangeType string
|
||
enabled bool
|
||
apiKey string
|
||
secretKey string
|
||
passphrase string
|
||
hyperliquidWalletAddr string
|
||
asterUser string
|
||
asterSigner string
|
||
asterPrivateKey string
|
||
lighterWalletAddr string
|
||
lighterPrivateKey string
|
||
lighterAPIKeyPrivateKey string
|
||
}
|
||
|
||
func (v exchangeConfigValidator) Validate() error {
|
||
exchangeType := strings.ToLower(strings.TrimSpace(v.exchangeType))
|
||
if exchangeType == "" {
|
||
return fmt.Errorf("exchange_type is required")
|
||
}
|
||
if trimmed := strings.TrimSpace(v.apiKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) {
|
||
return fmt.Errorf("API Key format looks invalid")
|
||
}
|
||
if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) {
|
||
return fmt.Errorf("Secret format looks invalid")
|
||
}
|
||
if v.enabled {
|
||
missing := store.MissingRequiredExchangeCredentialFields(
|
||
exchangeType,
|
||
v.apiKey,
|
||
v.secretKey,
|
||
v.passphrase,
|
||
v.hyperliquidWalletAddr,
|
||
v.asterUser,
|
||
v.asterSigner,
|
||
v.asterPrivateKey,
|
||
v.lighterWalletAddr,
|
||
v.lighterAPIKeyPrivateKey,
|
||
)
|
||
if len(missing) > 0 {
|
||
return fmt.Errorf("cannot enable exchange config before required fields are complete: %s", strings.Join(missing, ", "))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type traderBindingValidator struct {
|
||
store *store.Store
|
||
storeUserID string
|
||
aiModelID string
|
||
exchangeID string
|
||
strategyID string
|
||
}
|
||
|
||
func (v traderBindingValidator) Validate() error {
|
||
if v.store == nil {
|
||
return fmt.Errorf("store unavailable")
|
||
}
|
||
if strings.TrimSpace(v.aiModelID) == "" {
|
||
return fmt.Errorf("ai_model_id is required")
|
||
}
|
||
if strings.TrimSpace(v.exchangeID) == "" {
|
||
return fmt.Errorf("exchange_id is required")
|
||
}
|
||
model, err := v.store.AIModel().Get(v.storeUserID, strings.TrimSpace(v.aiModelID))
|
||
if err != nil {
|
||
return fmt.Errorf("invalid ai_model_id: %w", err)
|
||
}
|
||
if !model.Enabled {
|
||
return fmt.Errorf("ai model is disabled")
|
||
}
|
||
if !modelConfigUsable(model.Provider, model.ID, strings.TrimSpace(string(model.APIKey)), strings.TrimSpace(model.CustomAPIURL), strings.TrimSpace(model.CustomModelName)) {
|
||
return fmt.Errorf("ai model config is incomplete")
|
||
}
|
||
exchange, err := v.store.Exchange().GetByID(v.storeUserID, strings.TrimSpace(v.exchangeID))
|
||
if err != nil {
|
||
return fmt.Errorf("invalid exchange_id: %w", err)
|
||
}
|
||
if !exchange.Enabled {
|
||
return fmt.Errorf("exchange is disabled")
|
||
}
|
||
if err := (exchangeConfigValidator{
|
||
exchangeType: exchange.ExchangeType,
|
||
enabled: exchange.Enabled,
|
||
apiKey: strings.TrimSpace(string(exchange.APIKey)),
|
||
secretKey: strings.TrimSpace(string(exchange.SecretKey)),
|
||
passphrase: strings.TrimSpace(string(exchange.Passphrase)),
|
||
hyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||
asterUser: exchange.AsterUser,
|
||
asterSigner: exchange.AsterSigner,
|
||
asterPrivateKey: strings.TrimSpace(string(exchange.AsterPrivateKey)),
|
||
lighterWalletAddr: exchange.LighterWalletAddr,
|
||
lighterPrivateKey: strings.TrimSpace(string(exchange.LighterPrivateKey)),
|
||
lighterAPIKeyPrivateKey: strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)),
|
||
}).Validate(); err != nil {
|
||
return fmt.Errorf("exchange config is incomplete: %w", err)
|
||
}
|
||
if trimmed := strings.TrimSpace(v.strategyID); trimmed != "" {
|
||
if _, err := v.store.Strategy().Get(v.storeUserID, trimmed); err != nil {
|
||
return fmt.Errorf("invalid strategy_id: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Agent) validateModelDraft(storeUserID, modelID, provider string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||
if a == nil || a.store == nil {
|
||
return fmt.Errorf("store unavailable")
|
||
}
|
||
if strings.TrimSpace(provider) == "" && strings.TrimSpace(modelID) != "" {
|
||
model, err := a.store.AIModel().Get(storeUserID, strings.TrimSpace(modelID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
provider = model.Provider
|
||
if strings.TrimSpace(apiKey) == "" {
|
||
apiKey = strings.TrimSpace(string(model.APIKey))
|
||
}
|
||
if strings.TrimSpace(customAPIURL) == "" {
|
||
customAPIURL = strings.TrimSpace(model.CustomAPIURL)
|
||
}
|
||
if strings.TrimSpace(customModelName) == "" {
|
||
customModelName = strings.TrimSpace(model.CustomModelName)
|
||
}
|
||
}
|
||
return (modelConfigValidator{
|
||
provider: provider,
|
||
enabled: enabled,
|
||
apiKey: apiKey,
|
||
customAPIURL: customAPIURL,
|
||
customModelName: customModelName,
|
||
modelID: modelID,
|
||
}).Validate()
|
||
}
|
||
|
||
func (a *Agent) validateExchangeDraft(storeUserID, exchangeID, exchangeType string, enabled bool, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterAPIKeyPrivateKey string) error {
|
||
if a == nil || a.store == nil {
|
||
return fmt.Errorf("store unavailable")
|
||
}
|
||
if strings.TrimSpace(exchangeType) == "" && strings.TrimSpace(exchangeID) != "" {
|
||
exchange, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(exchangeID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
exchangeType = exchange.ExchangeType
|
||
if strings.TrimSpace(apiKey) == "" {
|
||
apiKey = strings.TrimSpace(string(exchange.APIKey))
|
||
}
|
||
if strings.TrimSpace(secretKey) == "" {
|
||
secretKey = strings.TrimSpace(string(exchange.SecretKey))
|
||
}
|
||
if strings.TrimSpace(passphrase) == "" {
|
||
passphrase = strings.TrimSpace(string(exchange.Passphrase))
|
||
}
|
||
if strings.TrimSpace(hyperliquidWalletAddr) == "" {
|
||
hyperliquidWalletAddr = strings.TrimSpace(exchange.HyperliquidWalletAddr)
|
||
}
|
||
if strings.TrimSpace(asterUser) == "" {
|
||
asterUser = strings.TrimSpace(exchange.AsterUser)
|
||
}
|
||
if strings.TrimSpace(asterSigner) == "" {
|
||
asterSigner = strings.TrimSpace(exchange.AsterSigner)
|
||
}
|
||
if strings.TrimSpace(asterPrivateKey) == "" {
|
||
asterPrivateKey = strings.TrimSpace(string(exchange.AsterPrivateKey))
|
||
}
|
||
if strings.TrimSpace(lighterWalletAddr) == "" {
|
||
lighterWalletAddr = strings.TrimSpace(exchange.LighterWalletAddr)
|
||
}
|
||
if strings.TrimSpace(lighterAPIKeyPrivateKey) == "" {
|
||
lighterAPIKeyPrivateKey = strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey))
|
||
}
|
||
}
|
||
return (exchangeConfigValidator{
|
||
exchangeType: exchangeType,
|
||
enabled: enabled,
|
||
apiKey: apiKey,
|
||
secretKey: secretKey,
|
||
passphrase: passphrase,
|
||
hyperliquidWalletAddr: hyperliquidWalletAddr,
|
||
asterUser: asterUser,
|
||
asterSigner: asterSigner,
|
||
asterPrivateKey: asterPrivateKey,
|
||
lighterWalletAddr: lighterWalletAddr,
|
||
lighterAPIKeyPrivateKey: lighterAPIKeyPrivateKey,
|
||
}).Validate()
|
||
}
|
||
|
||
func (a *Agent) validateTraderDraft(storeUserID, aiModelID, exchangeID, strategyID string) error {
|
||
return (traderBindingValidator{
|
||
store: a.store,
|
||
storeUserID: storeUserID,
|
||
aiModelID: aiModelID,
|
||
exchangeID: exchangeID,
|
||
strategyID: strategyID,
|
||
}).Validate()
|
||
}
|
||
|
||
func formatValidationFeedback(lang, domain string, err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
raw := strings.TrimSpace(err.Error())
|
||
lower := strings.ToLower(raw)
|
||
if lang == "zh" {
|
||
switch {
|
||
case strings.Contains(lower, "openai api key format looks invalid"):
|
||
return "这份配置还有问题:API Key 格式不对。OpenAI 的 API Key 通常以 `sk-` 开头,请直接发完整 Key,我继续帮你补进当前草稿。"
|
||
case strings.Contains(lower, "api key format looks invalid"):
|
||
return "这份配置还有问题:API Key 格式不对。请直接发完整的 API Key,不要附带多余说明文字。"
|
||
case strings.Contains(lower, "secret format looks invalid"):
|
||
return "这份配置还有问题:Secret 格式不对。请直接发完整的 Secret 值,不要和 API Key 填反。"
|
||
case strings.Contains(lower, "okx requires passphrase"):
|
||
return "这份配置还有问题:OKX 账户缺少 Passphrase,启用前需要补齐。你直接把 Passphrase 发我就行。"
|
||
case strings.Contains(lower, "hyperliquid requires wallet address"):
|
||
return "这份配置还有问题:Hyperliquid 账户缺少钱包地址,启用前需要补齐。"
|
||
case strings.Contains(lower, "aster requires user, signer, and private key"):
|
||
return "这份配置还有问题:Aster 账户还缺 user、signer 和 private key,启用前需要补齐。"
|
||
case strings.Contains(lower, "lighter requires wallet address and api key private key"):
|
||
return "这份配置还有问题:Lighter 账户还缺钱包地址和 API key private key,启用前需要补齐。"
|
||
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
|
||
return "这份配置还有问题:要先把 API Key、接口地址和模型名称配完整,才能启用。你可以继续把缺的字段发给我。"
|
||
case strings.Contains(lower, "unsupported provider"):
|
||
return "这份配置还有问题:provider 不在支持范围内。请从 OpenAI、DeepSeek、Claude、Gemini、Qwen、Kimi、Grok、Minimax 里选一个。"
|
||
case strings.Contains(lower, "invalid custom_api_url"):
|
||
return "这份配置还有问题:接口地址格式不对。请给我完整的 URL,或直接说使用默认地址。"
|
||
case strings.Contains(lower, "ai model is disabled"):
|
||
return "这份配置还有问题:绑定的模型当前是禁用状态。请换一个已启用模型,或先启用这个模型。"
|
||
case strings.Contains(lower, "exchange is disabled"):
|
||
return "这份配置还有问题:绑定的交易所当前已禁用。请换一个已启用交易所,或先启用这个交易所。"
|
||
case strings.Contains(lower, "ai model config is incomplete"):
|
||
return "这份配置还有问题:绑定的模型配置还没补完整,暂时不能使用。"
|
||
case strings.Contains(lower, "invalid ai_model_id"):
|
||
return "这份配置还有问题:模型引用无效。请明确告诉我你要绑定哪个模型。"
|
||
case strings.Contains(lower, "invalid exchange_id"):
|
||
return "这份配置还有问题:交易所引用无效。请明确告诉我你要绑定哪个交易所。"
|
||
case strings.Contains(lower, "invalid strategy_id"):
|
||
return "这份配置还有问题:策略引用无效。请明确告诉我你要绑定哪个策略。"
|
||
case strings.Contains(lower, "provider is required"):
|
||
return "这份配置还缺 provider。请先告诉我你要用哪个模型提供商。"
|
||
case strings.Contains(lower, "exchange_type is required"):
|
||
return "这份配置还缺交易所类型。请先告诉我你要接哪个交易所。"
|
||
}
|
||
switch domain {
|
||
case "model":
|
||
return "这份模型草稿还有问题:" + raw
|
||
case "exchange":
|
||
return "这份交易所草稿还有问题:" + raw
|
||
case "trader":
|
||
return "这份交易员草稿还有问题:" + raw
|
||
case "strategy":
|
||
return "这份策略草稿还有问题:" + raw
|
||
default:
|
||
return "这份配置还有问题:" + raw
|
||
}
|
||
}
|
||
|
||
switch {
|
||
case strings.Contains(lower, "openai api key format looks invalid"):
|
||
return "This draft still has an issue: the API key format looks wrong. OpenAI keys usually start with `sk-`. Send the full key and I'll keep filling the draft."
|
||
case strings.Contains(lower, "api key format looks invalid"):
|
||
return "This draft still has an issue: the API key format looks wrong. Send the full API key directly."
|
||
case strings.Contains(lower, "secret format looks invalid"):
|
||
return "This draft still has an issue: the secret format looks wrong. Send the full secret value directly."
|
||
case strings.Contains(lower, "okx requires passphrase"):
|
||
return "This draft still has an issue: an OKX config needs a passphrase before it can be enabled. Send the passphrase and I'll keep going."
|
||
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
|
||
return "This draft still has an issue: the API key, endpoint URL, and model name must be completed before the config can be enabled."
|
||
}
|
||
switch domain {
|
||
case "model":
|
||
return "This model draft still has an issue: " + raw
|
||
case "exchange":
|
||
return "This exchange draft still has an issue: " + raw
|
||
case "trader":
|
||
return "This trader draft still has an issue: " + raw
|
||
case "strategy":
|
||
return "This strategy draft still has an issue: " + raw
|
||
default:
|
||
return "This draft still has an issue: " + raw
|
||
}
|
||
}
|
||
|
||
func normalizeTraderArgsToManualLimits(lang string, args traderUpdateArgs) (traderUpdateArgs, []string) {
|
||
warnings := make([]string, 0, 2)
|
||
if args.ScanIntervalMinutes != nil {
|
||
requested := *args.ScanIntervalMinutes
|
||
normalized := requested
|
||
if normalized < manualTraderScanIntervalMin {
|
||
normalized = manualTraderScanIntervalMin
|
||
}
|
||
if normalized > manualTraderScanIntervalMax {
|
||
normalized = manualTraderScanIntervalMax
|
||
}
|
||
if normalized != requested {
|
||
args.ScanIntervalMinutes = &normalized
|
||
if lang == "zh" {
|
||
warnings = append(warnings, fmt.Sprintf("扫描间隔手动可配置范围是 %d 到 %d 分钟,已从 %d 调整为 %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
|
||
} else {
|
||
warnings = append(warnings, fmt.Sprintf("scan interval is limited to %d-%d minutes in the manual config, adjusted from %d to %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
|
||
}
|
||
}
|
||
}
|
||
return args, warnings
|
||
}
|
||
|
||
func formatRiskControlAcceptancePrompt(lang string, warnings []string, confirmLabel string) string {
|
||
if len(warnings) == 0 {
|
||
return ""
|
||
}
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
"这些配置超出了手动面板允许的范围,我已经先按风控范围收敛:",
|
||
}
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
lines = append(lines, fmt.Sprintf("如果接受当前范围,回复“%s”;也可以继续告诉我你想怎么改。", confirmLabel))
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
lines := []string{
|
||
"Some values were outside the manual editor limits, so I normalized them first:",
|
||
}
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func formatRiskControlRefusalPrompt(lang string, warnings []string, confirmLabel string) string {
|
||
if len(warnings) == 0 {
|
||
return ""
|
||
}
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
"这些配置超出了手动面板允许的范围,本次不会按你给的原值直接保存:",
|
||
}
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
lines = append(lines, fmt.Sprintf("如果接受当前安全范围,回复“%s”;也可以继续告诉我你想怎么改。", confirmLabel))
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
lines := []string{
|
||
"Some values were outside the manual editor limits, so I did not save the original request as-is:",
|
||
}
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func marshalStringList(values []string) string {
|
||
if len(values) == 0 {
|
||
return ""
|
||
}
|
||
raw, err := json.Marshal(values)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
func unmarshalStringList(raw string) []string {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return nil
|
||
}
|
||
var values []string
|
||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||
return nil
|
||
}
|
||
return values
|
||
}
|
||
|
||
func normalizeExchangePatchToManualLimits(lang string, patch exchangeUpdatePatch) (exchangeUpdatePatch, []string) {
|
||
warnings := make([]string, 0, 1)
|
||
if patch.LighterAPIKeyIndex != nil {
|
||
requested := *patch.LighterAPIKeyIndex
|
||
normalized := requested
|
||
if normalized < manualLighterAPIKeyIndexMin {
|
||
normalized = manualLighterAPIKeyIndexMin
|
||
}
|
||
if normalized > manualLighterAPIKeyIndexMax {
|
||
normalized = manualLighterAPIKeyIndexMax
|
||
}
|
||
if normalized != requested {
|
||
patch.LighterAPIKeyIndex = &normalized
|
||
if lang == "zh" {
|
||
warnings = append(warnings, fmt.Sprintf("Lighter API Key Index 手动面板范围是 %d 到 %d,已从 %d 调整为 %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
|
||
} else {
|
||
warnings = append(warnings, fmt.Sprintf("lighter API key index is limited to %d-%d in the manual editor, adjusted from %d to %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
|
||
}
|
||
}
|
||
}
|
||
return patch, warnings
|
||
}
|