mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 10:14:49 +00:00
refactor(onboard): simplify to DB + keys only, delegate setup to web UI
Remove the 700+ line interactive wizard (provider, model, channels, TTS, features) since the web UI setup wizard already handles all of this. The onboard command now only: prompts for Postgres DSN, tests connection, generates gateway token + encryption key, runs migrations, seeds placeholder providers, and saves .env.local with the 3 required secrets. Delete 8 helper files: onboard_auto, onboard_verify, onboard_models, onboard_resolve, onboard_claude_cli, onboard_tts, onboard_feishu, onboard_zalo.
This commit is contained in:
1 parent
a1db0fb666
commit
3187196bd8
11 files changed
+130
-1920
No files matched your search
+104
-660
@@ -1,595 +1,63 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
)
|
||||
|
||||
func onboardCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "onboard",
|
||||
Short: "Interactive setup wizard — configure provider, model, gateway, channels",
|
||||
Short: "Quick setup — configure database, generate keys, run migrations",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runOnboard()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
name string
|
||||
envKey string
|
||||
modelHint string
|
||||
}
|
||||
|
||||
var providerMap = map[string]providerInfo{
|
||||
"openrouter": {"openrouter", "GOCLAW_OPENROUTER_API_KEY", "anthropic/claude-sonnet-4-5-20250929"},
|
||||
"anthropic": {"anthropic", "GOCLAW_ANTHROPIC_API_KEY", "claude-sonnet-4-5-20250929"},
|
||||
"openai": {"openai", "GOCLAW_OPENAI_API_KEY", "gpt-4o"},
|
||||
"groq": {"groq", "GOCLAW_GROQ_API_KEY", "llama-3.3-70b-versatile"},
|
||||
"deepseek": {"deepseek", "GOCLAW_DEEPSEEK_API_KEY", "deepseek-chat"},
|
||||
"gemini": {"gemini", "GOCLAW_GEMINI_API_KEY", "gemini-2.0-flash"},
|
||||
"mistral": {"mistral", "GOCLAW_MISTRAL_API_KEY", "mistral-large-latest"},
|
||||
"xai": {"xai", "GOCLAW_XAI_API_KEY", "grok-3-mini"},
|
||||
"minimax": {"minimax", "GOCLAW_MINIMAX_API_KEY", "MiniMax-M2.5"},
|
||||
"cohere": {"cohere", "GOCLAW_COHERE_API_KEY", "command-a"},
|
||||
"perplexity": {"perplexity", "GOCLAW_PERPLEXITY_API_KEY", "sonar-pro"},
|
||||
"claude_cli": {"claude-cli", "", "sonnet"},
|
||||
"custom": {"custom", "", ""},
|
||||
}
|
||||
|
||||
func runOnboard() {
|
||||
// If env vars provide API keys, skip interactive wizard entirely.
|
||||
if canAutoOnboard() {
|
||||
cfgPath := resolveConfigPath()
|
||||
fmt.Println("Environment variables detected. Running non-interactive setup...")
|
||||
if runAutoOnboard(cfgPath) {
|
||||
return
|
||||
}
|
||||
fmt.Println("Auto-onboard failed, falling through to interactive wizard...")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println("╔══════════════════════════════════════════════╗")
|
||||
fmt.Println("║ GoClaw — Setup Wizard ║")
|
||||
fmt.Println("║ GoClaw — Quick Setup ║")
|
||||
fmt.Println("╚══════════════════════════════════════════════╝")
|
||||
fmt.Println()
|
||||
|
||||
// Determine config path
|
||||
cfgPath := resolveConfigPath()
|
||||
cfg := config.Default()
|
||||
|
||||
// Check existing config
|
||||
var cfg *config.Config
|
||||
isNewConfig := true
|
||||
// Load existing config if present (preserve gateway port, etc.)
|
||||
if _, err := os.Stat(cfgPath); err == nil {
|
||||
fmt.Printf("Found existing config at %s\n", cfgPath)
|
||||
useExisting, err := promptConfirm("Use existing config as base?", true)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
if useExisting {
|
||||
loaded, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: could not load existing config: %v\n", err)
|
||||
cfg = config.Default()
|
||||
} else {
|
||||
cfg = loaded
|
||||
isNewConfig = false
|
||||
}
|
||||
} else {
|
||||
cfg = config.Default()
|
||||
}
|
||||
} else {
|
||||
cfg = config.Default()
|
||||
}
|
||||
|
||||
// --- Declare all wizard variables (pre-filled from existing config) ---
|
||||
var (
|
||||
providerChoice = cfg.Agents.Defaults.Provider
|
||||
apiKey string
|
||||
customAPIBase string
|
||||
customModel string
|
||||
modelChoice = cfg.Agents.Defaults.Model
|
||||
orModelChoice = cfg.Agents.Defaults.Model
|
||||
|
||||
portStr = strconv.Itoa(cfg.Gateway.Port)
|
||||
|
||||
selectedChannels []string
|
||||
telegramToken string
|
||||
zaloToken string
|
||||
zaloDMPolicy = "pairing"
|
||||
feishuAppID string
|
||||
feishuSecret string
|
||||
feishuDomain = "lark"
|
||||
feishuConnMode = "websocket"
|
||||
|
||||
selectedFeatures []string
|
||||
embProvider string
|
||||
|
||||
ttsProvider = "none"
|
||||
ttsAPIKey string
|
||||
ttsGroupID string
|
||||
ttsAutoMode = "off"
|
||||
|
||||
postgresDSN string
|
||||
traceVerbose bool
|
||||
)
|
||||
|
||||
if isNewConfig {
|
||||
providerChoice = "openrouter"
|
||||
selectedFeatures = []string{"memory", "browser"}
|
||||
}
|
||||
if portStr == "0" {
|
||||
portStr = "18790"
|
||||
}
|
||||
|
||||
// Pre-fill from existing config
|
||||
if cfg.Channels.Telegram.Enabled {
|
||||
selectedChannels = append(selectedChannels, "telegram")
|
||||
telegramToken = cfg.Channels.Telegram.Token
|
||||
}
|
||||
if cfg.Channels.Zalo.Enabled {
|
||||
selectedChannels = append(selectedChannels, "zalo")
|
||||
zaloToken = cfg.Channels.Zalo.Token
|
||||
zaloDMPolicy = cfg.Channels.Zalo.DMPolicy
|
||||
}
|
||||
if cfg.Channels.Feishu.Enabled {
|
||||
selectedChannels = append(selectedChannels, "feishu")
|
||||
feishuAppID = cfg.Channels.Feishu.AppID
|
||||
feishuSecret = cfg.Channels.Feishu.AppSecret
|
||||
feishuDomain = cfg.Channels.Feishu.Domain
|
||||
feishuConnMode = cfg.Channels.Feishu.ConnectionMode
|
||||
}
|
||||
if cfg.Agents.Defaults.Memory != nil && (cfg.Agents.Defaults.Memory.Enabled == nil || *cfg.Agents.Defaults.Memory.Enabled) {
|
||||
selectedFeatures = append(selectedFeatures, "memory")
|
||||
embProvider = cfg.Agents.Defaults.Memory.EmbeddingProvider
|
||||
}
|
||||
if cfg.Tools.Browser.Enabled {
|
||||
selectedFeatures = append(selectedFeatures, "browser")
|
||||
}
|
||||
if cfg.Tts.Provider != "" {
|
||||
ttsProvider = cfg.Tts.Provider
|
||||
ttsAutoMode = cfg.Tts.Auto
|
||||
ttsAPIKey = cfg.Tts.OpenAI.APIKey
|
||||
if ttsAPIKey == "" {
|
||||
ttsAPIKey = cfg.Tts.ElevenLabs.APIKey
|
||||
}
|
||||
if ttsAPIKey == "" {
|
||||
ttsAPIKey = cfg.Tts.MiniMax.APIKey
|
||||
}
|
||||
ttsGroupID = cfg.Tts.MiniMax.GroupID
|
||||
}
|
||||
postgresDSN = cfg.Database.PostgresDSN
|
||||
|
||||
// Pre-fill API key from env or config
|
||||
apiKey = resolveExistingAPIKey(cfg, providerChoice)
|
||||
if cfg.Providers.OpenAI.APIBase != "" {
|
||||
customAPIBase = cfg.Providers.OpenAI.APIBase
|
||||
}
|
||||
|
||||
// ── Step 1: Provider ──
|
||||
providerOptions := []SelectOption[string]{
|
||||
{"OpenRouter (recommended — access to many models)", "openrouter"},
|
||||
{"Anthropic (Claude models directly)", "anthropic"},
|
||||
{"OpenAI (GPT models)", "openai"},
|
||||
{"Groq (fast inference)", "groq"},
|
||||
{"DeepSeek (DeepSeek models)", "deepseek"},
|
||||
{"Gemini (Google Gemini)", "gemini"},
|
||||
{"Mistral (Mistral AI models)", "mistral"},
|
||||
{"xAI (Grok models)", "xai"},
|
||||
{"MiniMax (MiniMax models)", "minimax"},
|
||||
{"Cohere (Command models)", "cohere"},
|
||||
{"Perplexity (Sonar search models)", "perplexity"},
|
||||
{"Claude CLI (use local claude CLI — no API key needed)", "claude_cli"},
|
||||
{"Custom (any OpenAI-compatible endpoint)", "custom"},
|
||||
}
|
||||
|
||||
var err error
|
||||
providerChoice, err = promptSelect("Step 1 · AI Provider — Choose your LLM provider", providerOptions, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Claude CLI variables
|
||||
var cliPath, cliModel string
|
||||
if cfg.Providers.ClaudeCLI.CLIPath != "" {
|
||||
cliPath = cfg.Providers.ClaudeCLI.CLIPath
|
||||
cliModel = cfg.Providers.ClaudeCLI.Model
|
||||
}
|
||||
|
||||
// Provider-specific prompts
|
||||
switch providerChoice {
|
||||
case "claude_cli":
|
||||
if cliPath == "" {
|
||||
cliPath = "claude"
|
||||
}
|
||||
cliPath, err = promptString("Claude CLI Path", "Path to the claude binary", cliPath)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify CLI binary exists
|
||||
if _, lookErr := exec.LookPath(cliPath); lookErr != nil {
|
||||
fmt.Printf(" ✗ Claude CLI binary not found at %q\n", cliPath)
|
||||
fmt.Println(" Install Claude CLI first: https://docs.anthropic.com/en/docs/claude-cli")
|
||||
return
|
||||
}
|
||||
|
||||
// Check authentication status
|
||||
fmt.Print(" Checking Claude CLI authentication... ")
|
||||
authStatus, authErr := providers.CheckClaudeAuthStatus(context.Background(), cliPath)
|
||||
if authErr != nil || !authStatus.LoggedIn {
|
||||
if authErr != nil {
|
||||
fmt.Println("FAILED")
|
||||
fmt.Printf(" %v\n", authErr)
|
||||
} else {
|
||||
fmt.Println("NOT LOGGED IN")
|
||||
}
|
||||
fmt.Println()
|
||||
runLogin, confirmErr := promptConfirm("Claude CLI requires authentication. Run login now?", true)
|
||||
if confirmErr != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
if runLogin {
|
||||
if loginErr := runClaudeAuthLogin(cliPath); loginErr != nil {
|
||||
fmt.Printf(" Login failed: %v\n", loginErr)
|
||||
fmt.Println(" You can try manually: claude auth login")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" Skipping login. Run 'claude auth login' before starting the gateway.")
|
||||
}
|
||||
} else {
|
||||
sub := authStatus.SubscriptionType
|
||||
if sub == "" {
|
||||
sub = "unknown"
|
||||
}
|
||||
fmt.Printf("OK\n")
|
||||
fmt.Printf(" ✓ Authenticated as %s (subscription: %s)\n", authStatus.Email, sub)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if cliModel == "" {
|
||||
cliModel = "sonnet"
|
||||
}
|
||||
cliModel, err = promptString("Default Model", "Model alias: sonnet, opus, haiku", cliModel)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
case "custom":
|
||||
customAPIBase, err = promptString("API Base URL", "OpenAI-compatible endpoint (e.g. Ollama, vLLM, LiteLLM)", customAPIBase)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
apiKey, err = promptPassword("API Key", "Leave empty if not required")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
customModel, err = promptString("Model ID", "The model to use on this endpoint", customModel)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
case "openrouter":
|
||||
apiKey, err = promptPassword("OpenRouter API Key", "Get yours at https://openrouter.ai/keys")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch and select model
|
||||
fmt.Println(" Fetching OpenRouter models...")
|
||||
orModelOptions := buildOpenRouterModelOptions()
|
||||
orModelChoice, err = promptSelect("Choose OpenRouter Model", orModelOptions, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
default:
|
||||
// Standard provider
|
||||
apiKey, err = promptPassword("API Key", "Your provider API key (check env vars or dashboard)")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
modelChoice, err = promptString("Default Model", "Model ID to use (leave empty for provider default)", modelChoice)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
if loaded, err := config.Load(cfgPath); err == nil {
|
||||
cfg = loaded
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway Port ──
|
||||
portStr, err = promptString("Gateway Port", "WebSocket server port", portStr)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// ── Step 2: Channels ──
|
||||
selectedChannels, err = promptMultiSelect("Step 2 · Channels (select at least 1)", "Enter numbers to toggle channels", []SelectOption[string]{
|
||||
{"Telegram", "telegram"},
|
||||
{"Zalo OA", "zalo"},
|
||||
{"Feishu / Lark", "feishu"},
|
||||
}, selectedChannels)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Helper closures
|
||||
hasChannel := func(ch string) bool {
|
||||
for _, c := range selectedChannels {
|
||||
if c == ch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasFeature := func(f string) bool {
|
||||
for _, feat := range selectedFeatures {
|
||||
if feat == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Channel-specific configs
|
||||
if hasChannel("telegram") {
|
||||
telegramToken, err = promptPassword("Telegram Bot Token", "Get from @BotFather on Telegram")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
if telegramToken == "" {
|
||||
telegramToken = cfg.Channels.Telegram.Token // keep existing
|
||||
}
|
||||
}
|
||||
|
||||
if hasChannel("zalo") {
|
||||
if err := promptZaloConfig(&zaloToken, &zaloDMPolicy); err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if hasChannel("feishu") {
|
||||
if err := promptFeishuConfig(&feishuAppID, &feishuSecret, &feishuDomain, &feishuConnMode); err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Features ──
|
||||
selectedFeatures, err = promptMultiSelect("Features (recommended: keep both)", "Enter numbers to toggle features", []SelectOption[string]{
|
||||
{"Memory (vector search over agent notes)", "memory"},
|
||||
{"Browser automation (agent can browse the web)", "browser"},
|
||||
}, selectedFeatures)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// Memory embedding provider
|
||||
if hasFeature("memory") {
|
||||
embProvider, err = promptSelect("Memory Embedding Provider", []SelectOption[string]{
|
||||
{"Auto-detect (use chat provider's API key)", ""},
|
||||
{"OpenAI (text-embedding-3-small)", "openai"},
|
||||
{"OpenRouter (openai/text-embedding-3-small)", "openrouter"},
|
||||
{"Gemini (text-embedding-004)", "gemini"},
|
||||
}, 0)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS ──
|
||||
if err := promptTTSConfig(&ttsProvider, &ttsAPIKey, &ttsGroupID, &ttsAutoMode); err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// ── Verbose Tracing ──
|
||||
traceVerbose, err = promptConfirm("Enable verbose tracing? (Logs full LLM input in trace spans)", false)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// ── Step 3: Database ──
|
||||
postgresDSN, err = promptString("Step 3 · Postgres DSN", "Connection string (e.g. postgres://user:pass@host:5432/dbname)", postgresDSN)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
|
||||
// --- Post-form validation ---
|
||||
var errors []string
|
||||
|
||||
if providerChoice == "custom" {
|
||||
if customAPIBase == "" {
|
||||
errors = append(errors, "API base URL is required for custom provider")
|
||||
}
|
||||
if customModel == "" {
|
||||
errors = append(errors, "Model ID is required for custom provider")
|
||||
}
|
||||
} else if providerChoice == "claude_cli" {
|
||||
if cliPath == "" {
|
||||
errors = append(errors, "Claude CLI path is required")
|
||||
}
|
||||
} else if apiKey == "" {
|
||||
errors = append(errors, fmt.Sprintf("API key is required for %s", providerChoice))
|
||||
}
|
||||
|
||||
if _, err := strconv.Atoi(portStr); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Invalid gateway port: %s", portStr))
|
||||
}
|
||||
|
||||
if len(selectedChannels) == 0 {
|
||||
errors = append(errors, "At least one channel must be selected (Telegram, Zalo, or Feishu)")
|
||||
}
|
||||
|
||||
if hasChannel("telegram") && telegramToken == "" {
|
||||
errors = append(errors, "Telegram bot token is required")
|
||||
}
|
||||
if hasChannel("zalo") && zaloToken == "" {
|
||||
errors = append(errors, "Zalo bot token is required")
|
||||
}
|
||||
if hasChannel("feishu") && (feishuAppID == "" || feishuSecret == "") {
|
||||
errors = append(errors, "Feishu App ID and App Secret are required")
|
||||
}
|
||||
|
||||
// ── Step 1: Postgres DSN ──
|
||||
postgresDSN := os.Getenv("GOCLAW_POSTGRES_DSN")
|
||||
if postgresDSN == "" {
|
||||
errors = append(errors, "Postgres DSN is required (set GOCLAW_POSTGRES_DSN or enter above)")
|
||||
postgresDSN = cfg.Database.PostgresDSN
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(" Validation errors:")
|
||||
for _, e := range errors {
|
||||
fmt.Printf(" • %s\n", e)
|
||||
if postgresDSN == "" {
|
||||
var err error
|
||||
postgresDSN, err = promptString("Postgres DSN", "Connection string (e.g. postgres://user:pass@host:5432/dbname)", "")
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" Please re-run: ./goclaw onboard")
|
||||
} else {
|
||||
fmt.Printf(" Using Postgres DSN from environment\n")
|
||||
}
|
||||
if postgresDSN == "" {
|
||||
fmt.Println(" Error: Postgres DSN is required.")
|
||||
return
|
||||
}
|
||||
|
||||
// --- Apply collected values to config ---
|
||||
|
||||
// Provider & model
|
||||
if providerChoice == "claude_cli" {
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.Agents.Defaults.Model = cliModel
|
||||
cfg.Providers.ClaudeCLI.CLIPath = cliPath
|
||||
cfg.Providers.ClaudeCLI.Model = cliModel
|
||||
} else if providerChoice == "custom" {
|
||||
cfg.Agents.Defaults.Provider = "openai"
|
||||
cfg.Providers.OpenAI.APIBase = customAPIBase
|
||||
cfg.Providers.OpenAI.APIKey = apiKey
|
||||
cfg.Agents.Defaults.Model = customModel
|
||||
} else {
|
||||
pi := providerMap[providerChoice]
|
||||
cfg.Agents.Defaults.Provider = pi.name
|
||||
applyProviderAPIKey(cfg, pi.name, apiKey)
|
||||
|
||||
if providerChoice == "openrouter" {
|
||||
if orModelChoice == "__custom__" {
|
||||
cfg.Agents.Defaults.Model = pi.modelHint
|
||||
} else {
|
||||
cfg.Agents.Defaults.Model = orModelChoice
|
||||
}
|
||||
} else {
|
||||
if modelChoice == "" {
|
||||
modelChoice = pi.modelHint
|
||||
}
|
||||
cfg.Agents.Defaults.Model = modelChoice
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway
|
||||
cfg.Gateway.Port, _ = strconv.Atoi(portStr)
|
||||
if cfg.Gateway.Host == "" {
|
||||
cfg.Gateway.Host = "0.0.0.0"
|
||||
}
|
||||
if cfg.Gateway.Token == "" {
|
||||
cfg.Gateway.Token = onboardGenerateToken(16)
|
||||
fmt.Printf(" Generated gateway token: %s\n", cfg.Gateway.Token)
|
||||
}
|
||||
|
||||
// Workspace (use default, no prompt)
|
||||
cfg.Agents.Defaults.Workspace = "~/.goclaw/workspace"
|
||||
expandedWS := config.ExpandHome(cfg.Agents.Defaults.Workspace)
|
||||
if err := os.MkdirAll(expandedWS, 0755); err != nil {
|
||||
fmt.Printf("Warning: could not create workspace: %v\n", err)
|
||||
}
|
||||
|
||||
// Channels
|
||||
cfg.Channels.Telegram.Enabled = hasChannel("telegram")
|
||||
if cfg.Channels.Telegram.Enabled {
|
||||
cfg.Channels.Telegram.Token = telegramToken
|
||||
cfg.Channels.Telegram.DMPolicy = "pairing"
|
||||
}
|
||||
|
||||
cfg.Channels.Zalo.Enabled = hasChannel("zalo")
|
||||
if cfg.Channels.Zalo.Enabled {
|
||||
cfg.Channels.Zalo.Token = zaloToken
|
||||
cfg.Channels.Zalo.DMPolicy = zaloDMPolicy
|
||||
}
|
||||
|
||||
cfg.Channels.Feishu.Enabled = hasChannel("feishu")
|
||||
if cfg.Channels.Feishu.Enabled {
|
||||
cfg.Channels.Feishu.AppID = feishuAppID
|
||||
cfg.Channels.Feishu.AppSecret = feishuSecret
|
||||
cfg.Channels.Feishu.Domain = feishuDomain
|
||||
cfg.Channels.Feishu.ConnectionMode = feishuConnMode
|
||||
}
|
||||
|
||||
// Features
|
||||
if hasFeature("memory") {
|
||||
enabled := true
|
||||
if cfg.Agents.Defaults.Memory == nil {
|
||||
cfg.Agents.Defaults.Memory = &config.MemoryConfig{}
|
||||
}
|
||||
cfg.Agents.Defaults.Memory.Enabled = &enabled
|
||||
cfg.Agents.Defaults.Memory.EmbeddingProvider = embProvider
|
||||
} else {
|
||||
disabled := false
|
||||
cfg.Agents.Defaults.Memory = &config.MemoryConfig{Enabled: &disabled}
|
||||
}
|
||||
cfg.Tools.Browser.Enabled = hasFeature("browser")
|
||||
if cfg.Tools.Browser.Enabled {
|
||||
cfg.Tools.Browser.Headless = true
|
||||
}
|
||||
|
||||
// TTS
|
||||
if ttsProvider != "none" {
|
||||
cfg.Tts.Provider = ttsProvider
|
||||
cfg.Tts.Auto = ttsAutoMode
|
||||
switch ttsProvider {
|
||||
case "openai":
|
||||
cfg.Tts.OpenAI.APIKey = ttsAPIKey
|
||||
case "elevenlabs":
|
||||
cfg.Tts.ElevenLabs.APIKey = ttsAPIKey
|
||||
case "minimax":
|
||||
cfg.Tts.MiniMax.APIKey = ttsAPIKey
|
||||
cfg.Tts.MiniMax.GroupID = ttsGroupID
|
||||
case "edge":
|
||||
cfg.Tts.Edge.Enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Database
|
||||
cfg.Database.PostgresDSN = postgresDSN
|
||||
|
||||
// Auto-generate encryption key for API keys in DB (if not already set).
|
||||
if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" {
|
||||
encKey := onboardGenerateToken(32)
|
||||
os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey)
|
||||
fmt.Printf(" Generated encryption key for API keys (AES-256-GCM)\n")
|
||||
} else {
|
||||
fmt.Println(" Using existing GOCLAW_ENCRYPTION_KEY from environment")
|
||||
}
|
||||
|
||||
// ── Step 2: Test connection ──
|
||||
fmt.Print(" Testing Postgres connection... ")
|
||||
if err := testPostgresConnection(postgresDSN); err != nil {
|
||||
fmt.Println("FAILED")
|
||||
@@ -599,135 +67,111 @@ func runOnboard() {
|
||||
}
|
||||
fmt.Println("OK")
|
||||
|
||||
runMigrate, err := promptConfirm("Run database migration now?", true)
|
||||
if err != nil {
|
||||
fmt.Println("Cancelled.")
|
||||
return
|
||||
// ── Step 3: Generate keys ──
|
||||
gatewayToken := os.Getenv("GOCLAW_GATEWAY_TOKEN")
|
||||
if gatewayToken == "" {
|
||||
gatewayToken = cfg.Gateway.Token
|
||||
}
|
||||
if runMigrate {
|
||||
fmt.Println(" Running migration...")
|
||||
m, err := newMigrator(postgresDSN)
|
||||
if err != nil {
|
||||
fmt.Printf(" Migration error: %v\n", err)
|
||||
generatedToken := false
|
||||
if gatewayToken == "" {
|
||||
gatewayToken = onboardGenerateToken(16)
|
||||
generatedToken = true
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
|
||||
generatedEncKey := false
|
||||
if encryptionKey == "" {
|
||||
encryptionKey = onboardGenerateToken(32)
|
||||
generatedEncKey = true
|
||||
}
|
||||
os.Setenv("GOCLAW_ENCRYPTION_KEY", encryptionKey)
|
||||
|
||||
// ── Step 4: Migrations ──
|
||||
fmt.Print(" Running migrations... ")
|
||||
m, err := newMigrator(postgresDSN)
|
||||
if err != nil {
|
||||
fmt.Printf("FAILED: %v\n", err)
|
||||
fmt.Println(" You can run it manually later: ./goclaw migrate up")
|
||||
} else {
|
||||
if err := m.Up(); err != nil && err.Error() != "no change" {
|
||||
fmt.Printf("FAILED: %v\n", err)
|
||||
fmt.Println(" You can run it manually later: ./goclaw migrate up")
|
||||
} else {
|
||||
if err := m.Up(); err != nil && err.Error() != "no change" {
|
||||
fmt.Printf(" Migration error: %v\n", err)
|
||||
fmt.Println(" You can run it manually later: ./goclaw migrate up")
|
||||
} else {
|
||||
v, _, _ := m.Version()
|
||||
fmt.Printf(" Migration complete (version: %d)\n", v)
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
fmt.Println(" Seeding default agent and provider...")
|
||||
if err := seedManagedData(postgresDSN, cfg); err != nil {
|
||||
fmt.Printf(" Seed warning: %v\n", err)
|
||||
fmt.Println(" You can seed manually via the API after starting the gateway.")
|
||||
} else {
|
||||
fmt.Println(" Default agent and provider seeded.")
|
||||
v, _, _ := m.Version()
|
||||
fmt.Printf("OK (version: %d)\n", v)
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
// --- Save config ---
|
||||
fmt.Println()
|
||||
fmt.Println("── Saving Config ──")
|
||||
fmt.Println()
|
||||
|
||||
savedProviders := cfg.Providers
|
||||
savedGwToken := cfg.Gateway.Token
|
||||
savedTgToken := cfg.Channels.Telegram.Token
|
||||
savedZaloToken := cfg.Channels.Zalo.Token
|
||||
savedFeishuAppSecret := cfg.Channels.Feishu.AppSecret
|
||||
savedTtsOpenAIKey := cfg.Tts.OpenAI.APIKey
|
||||
savedTtsElevenLabsKey := cfg.Tts.ElevenLabs.APIKey
|
||||
savedTtsMiniMaxKey := cfg.Tts.MiniMax.APIKey
|
||||
|
||||
// Clear secrets from providers but keep non-secret configs (like ClaudeCLI)
|
||||
cfg.Providers = config.ProvidersConfig{
|
||||
ClaudeCLI: savedProviders.ClaudeCLI, // no secrets, safe to save in config
|
||||
// ── Step 5: Seed placeholder providers for UI ──
|
||||
fmt.Print(" Seeding placeholder providers... ")
|
||||
if err := seedOnboardPlaceholders(postgresDSN); err != nil {
|
||||
fmt.Printf("warning: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("OK")
|
||||
}
|
||||
cfg.Gateway.Token = ""
|
||||
cfg.Channels.Telegram.Token = ""
|
||||
cfg.Channels.Zalo.Token = ""
|
||||
cfg.Channels.Feishu.AppSecret = ""
|
||||
cfg.Tts.OpenAI.APIKey = ""
|
||||
cfg.Tts.ElevenLabs.APIKey = ""
|
||||
cfg.Tts.MiniMax.APIKey = ""
|
||||
|
||||
saveErr := config.Save(cfgPath, cfg)
|
||||
// ── Step 6: Save config ──
|
||||
if cfg.Gateway.Host == "" {
|
||||
cfg.Gateway.Host = "0.0.0.0"
|
||||
}
|
||||
if cfg.Gateway.Port == 0 {
|
||||
cfg.Gateway.Port = 18790
|
||||
}
|
||||
cfg.Database.PostgresDSN = "" // secrets go in .env.local, not config
|
||||
cfg.Gateway.Token = "" // secrets go in .env.local, not config
|
||||
|
||||
cfg.Providers = savedProviders
|
||||
cfg.Gateway.Token = savedGwToken
|
||||
cfg.Channels.Telegram.Token = savedTgToken
|
||||
cfg.Channels.Zalo.Token = savedZaloToken
|
||||
cfg.Channels.Feishu.AppSecret = savedFeishuAppSecret
|
||||
cfg.Tts.OpenAI.APIKey = savedTtsOpenAIKey
|
||||
cfg.Tts.ElevenLabs.APIKey = savedTtsElevenLabsKey
|
||||
cfg.Tts.MiniMax.APIKey = savedTtsMiniMaxKey
|
||||
|
||||
if err := saveErr; err != nil {
|
||||
fmt.Printf("Error saving config: %v\n", err)
|
||||
if err := config.Save(cfgPath, cfg); err != nil {
|
||||
fmt.Printf(" Error saving config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Config saved to %s (no secrets)\n", cfgPath)
|
||||
|
||||
// ── Step 7: Save .env.local ──
|
||||
envPath := filepath.Join(filepath.Dir(cfgPath), ".env.local")
|
||||
pi := providerMap[providerChoice]
|
||||
onboardWriteEnvFile(envPath, cfg, apiKey, pi.envKey, traceVerbose)
|
||||
fmt.Printf("Secrets saved to %s\n", envPath)
|
||||
onboardWriteEnvFile(envPath, postgresDSN, gatewayToken, encryptionKey)
|
||||
|
||||
// ── Summary ──
|
||||
port := strconv.Itoa(cfg.Gateway.Port)
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
fmt.Println("╔══════════════════════════════════════════════╗")
|
||||
fmt.Println("║ Setup Complete! ║")
|
||||
fmt.Println("║ Setup Complete! ║")
|
||||
fmt.Println("╚══════════════════════════════════════════════╝")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Provider: %s\n", cfg.Agents.Defaults.Provider)
|
||||
fmt.Printf(" Model: %s\n", cfg.Agents.Defaults.Model)
|
||||
fmt.Printf(" Gateway: ws://%s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
fmt.Printf(" Token: %s\n", cfg.Gateway.Token)
|
||||
fmt.Printf(" Workspace: %s\n", cfg.Agents.Defaults.Workspace)
|
||||
fmt.Println(" Database: PostgreSQL")
|
||||
if cfg.Channels.Telegram.Enabled {
|
||||
fmt.Println(" Telegram: enabled")
|
||||
}
|
||||
if cfg.Channels.Zalo.Enabled {
|
||||
fmt.Println(" Zalo: enabled")
|
||||
}
|
||||
if cfg.Channels.Feishu.Enabled {
|
||||
fmt.Printf(" Feishu: enabled (%s, %s)\n", cfg.Channels.Feishu.Domain, cfg.Channels.Feishu.ConnectionMode)
|
||||
}
|
||||
if cfg.Agents.Defaults.Memory != nil && (cfg.Agents.Defaults.Memory.Enabled == nil || *cfg.Agents.Defaults.Memory.Enabled) {
|
||||
embProv := cfg.Agents.Defaults.Memory.EmbeddingProvider
|
||||
if embProv == "" {
|
||||
embProv = "auto-detect"
|
||||
|
||||
if generatedToken || generatedEncKey {
|
||||
fmt.Println("── Generated Secrets (shown once, saved to .env.local) ──")
|
||||
fmt.Println()
|
||||
if generatedToken {
|
||||
fmt.Printf(" Gateway Token: %s\n", gatewayToken)
|
||||
}
|
||||
fmt.Printf(" Memory: enabled (embedding: %s)\n", embProv)
|
||||
} else {
|
||||
fmt.Println(" Memory: disabled")
|
||||
}
|
||||
if cfg.Tools.Browser.RemoteURL != "" {
|
||||
fmt.Printf(" Browser: enabled (remote: %s)\n", cfg.Tools.Browser.RemoteURL)
|
||||
} else if cfg.Tools.Browser.Enabled {
|
||||
fmt.Println(" Browser: enabled (headless)")
|
||||
} else {
|
||||
fmt.Println(" Browser: disabled")
|
||||
}
|
||||
if cfg.Tts.Provider != "" {
|
||||
autoMode := cfg.Tts.Auto
|
||||
if autoMode == "" {
|
||||
autoMode = "off"
|
||||
if generatedEncKey {
|
||||
fmt.Printf(" Encryption Key: %s\n", encryptionKey)
|
||||
}
|
||||
fmt.Printf(" TTS: %s (auto: %s)\n", cfg.Tts.Provider, autoMode)
|
||||
} else {
|
||||
fmt.Println(" TTS: disabled")
|
||||
fmt.Println()
|
||||
fmt.Println(" ⚠ These keys are shown only once. They are saved in:")
|
||||
fmt.Printf(" → %s\n", envPath)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println("── Files ──")
|
||||
fmt.Println()
|
||||
fmt.Println("To start the gateway:")
|
||||
fmt.Printf(" Config: %s (gateway host/port, no secrets)\n", cfgPath)
|
||||
fmt.Printf(" Secrets: %s (GOCLAW_POSTGRES_DSN, GOCLAW_GATEWAY_TOKEN, GOCLAW_ENCRYPTION_KEY)\n", envPath)
|
||||
fmt.Println()
|
||||
fmt.Printf(" source %s && ./goclaw\n", envPath)
|
||||
|
||||
fmt.Println("── Next Steps ──")
|
||||
fmt.Println()
|
||||
fmt.Println(" 1. Start the gateway:")
|
||||
fmt.Printf(" source %s && ./goclaw\n", envPath)
|
||||
fmt.Println()
|
||||
fmt.Println(" 2. Open the dashboard to complete setup:")
|
||||
fmt.Printf(" http://localhost:%s\n", port)
|
||||
fmt.Println()
|
||||
fmt.Println(" The setup wizard will guide you through:")
|
||||
fmt.Println(" → Provider & API key configuration")
|
||||
fmt.Println(" → Model selection & verification")
|
||||
fmt.Println(" → Agent creation")
|
||||
fmt.Println(" → Channel setup (optional)")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// providerPriority defines the order in which providers are auto-detected
|
||||
// from environment variables. First match wins.
|
||||
var providerPriority = []string{
|
||||
"openrouter", "anthropic", "openai", "groq", "deepseek",
|
||||
"gemini", "mistral", "xai", "minimax", "cohere", "perplexity",
|
||||
}
|
||||
|
||||
// canAutoOnboard returns true if any GOCLAW_*_API_KEY env var is set,
|
||||
// indicating the user wants non-interactive configuration (e.g. Docker).
|
||||
func canAutoOnboard() bool {
|
||||
for _, name := range providerPriority {
|
||||
pi, ok := providerMap[name]
|
||||
if !ok || pi.envKey == "" {
|
||||
continue
|
||||
}
|
||||
if os.Getenv(pi.envKey) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runAutoOnboard performs non-interactive setup from environment variables.
|
||||
// Returns true on success, false on fatal error.
|
||||
func runAutoOnboard(cfgPath string) bool {
|
||||
fmt.Println("Auto-onboard: environment variables detected, running non-interactive setup...")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.ApplyEnvOverrides()
|
||||
|
||||
// 1. Resolve provider: respect GOCLAW_PROVIDER if set, otherwise auto-detect.
|
||||
provider := cfg.Agents.Defaults.Provider // may be set by GOCLAW_PROVIDER via ApplyEnvOverrides
|
||||
apiKey := ""
|
||||
if provider != "" {
|
||||
apiKey = resolveProviderAPIKey(cfg, provider)
|
||||
}
|
||||
if apiKey == "" {
|
||||
// No explicit provider or no API key for it — auto-detect from available keys
|
||||
provider, apiKey = detectProvider(cfg)
|
||||
}
|
||||
if provider == "" {
|
||||
fmt.Println("Auto-onboard: no provider API key found in environment")
|
||||
return false
|
||||
}
|
||||
cfg.Agents.Defaults.Provider = provider
|
||||
|
||||
// Use model hint if no model override set via GOCLAW_MODEL
|
||||
if cfg.Agents.Defaults.Model == "" || cfg.Agents.Defaults.Model == config.Default().Agents.Defaults.Model {
|
||||
if pi, ok := providerMap[provider]; ok && pi.modelHint != "" {
|
||||
cfg.Agents.Defaults.Model = pi.modelHint
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Provider: %s (model: %s)\n", provider, cfg.Agents.Defaults.Model)
|
||||
|
||||
// 2. Auto-enable memory: detect embedding-capable API keys from env.
|
||||
// Embedding providers: openai, openrouter, gemini (same order as resolveEmbeddingProvider).
|
||||
embProvider := autoDetectEmbeddingProvider(cfg)
|
||||
if embProvider != "" {
|
||||
enabled := true
|
||||
cfg.Agents.Defaults.Memory = &config.MemoryConfig{
|
||||
Enabled: &enabled,
|
||||
EmbeddingProvider: embProvider,
|
||||
}
|
||||
fmt.Printf(" Memory: enabled (embedding: %s)\n", embProvider)
|
||||
} else {
|
||||
fmt.Println(" Memory: enabled (FTS-only, no embedding API key)")
|
||||
enabled := true
|
||||
cfg.Agents.Defaults.Memory = &config.MemoryConfig{Enabled: &enabled}
|
||||
}
|
||||
|
||||
// 3. Gateway token
|
||||
if cfg.Gateway.Token == "" {
|
||||
cfg.Gateway.Token = onboardGenerateToken(16)
|
||||
slog.Info("auto-onboard: generated gateway token")
|
||||
}
|
||||
|
||||
// 3. Database: Postgres setup
|
||||
if cfg.Database.PostgresDSN != "" {
|
||||
fmt.Print(" Testing Postgres connection...")
|
||||
|
||||
// Retry loop: database container may still be starting
|
||||
var pgErr error
|
||||
for attempt := 1; attempt <= 5; attempt++ {
|
||||
pgErr = testPostgresConnection(cfg.Database.PostgresDSN)
|
||||
if pgErr == nil {
|
||||
break
|
||||
}
|
||||
if attempt < 5 {
|
||||
fmt.Printf(" retry %d/5...", attempt)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if pgErr != nil {
|
||||
fmt.Println(" FAILED")
|
||||
fmt.Printf(" Error: %v\n", pgErr)
|
||||
return false
|
||||
}
|
||||
fmt.Println(" OK")
|
||||
|
||||
// Generate encryption key if not set
|
||||
if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" {
|
||||
encKey := onboardGenerateToken(32)
|
||||
os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey)
|
||||
slog.Info("auto-onboard: generated encryption key")
|
||||
}
|
||||
|
||||
// Run migrations (idempotent)
|
||||
fmt.Print(" Running migrations...")
|
||||
m, err := newMigrator(cfg.Database.PostgresDSN)
|
||||
if err != nil {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
fmt.Println(" Continuing without migration (run manually: goclaw migrate up)")
|
||||
} else {
|
||||
if err := m.Up(); err != nil && err.Error() != "no change" {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
fmt.Println(" Continuing without migration (run manually: goclaw migrate up)")
|
||||
} else {
|
||||
v, _, _ := m.Version()
|
||||
fmt.Printf(" OK (version: %d)\n", v)
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
// Verify provider connectivity for all configured providers before seeding.
|
||||
// Only the primary provider's auth failure blocks bootstrap.
|
||||
fmt.Println(" Verifying provider connectivity...")
|
||||
if fatalErrors := verifyAllProviders(cfg, provider); len(fatalErrors) > 0 {
|
||||
slog.Error("auto-onboard: primary provider verification failed", "errors", fatalErrors)
|
||||
fmt.Printf(" Provider verification FAILED: primary provider %q has invalid API key\n", provider)
|
||||
return false
|
||||
}
|
||||
|
||||
// Seed default data (non-fatal if already exists)
|
||||
fmt.Print(" Seeding default agent/provider...")
|
||||
if err := seedManagedData(cfg.Database.PostgresDSN, cfg); err != nil {
|
||||
fmt.Printf(" skipped: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" OK")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save config (clean, minimal — secrets stripped, unused sections omitted)
|
||||
savedDSN := cfg.Database.PostgresDSN
|
||||
if err := saveCleanConfig(cfgPath, cfg); err != nil {
|
||||
fmt.Printf(" Warning: could not save config: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Config saved to %s\n", cfgPath)
|
||||
}
|
||||
|
||||
// Restore DSN and re-apply env overrides so the runtime config has secrets
|
||||
cfg.Database.PostgresDSN = savedDSN
|
||||
cfg.ApplyEnvOverrides()
|
||||
_ = apiKey // apiKey is already applied via ApplyEnvOverrides
|
||||
|
||||
fmt.Println("Auto-onboard complete.")
|
||||
return true
|
||||
}
|
||||
|
||||
// embeddingCapable lists providers that support text embeddings.
|
||||
// Only these three have embedding provider implementations in resolveEmbeddingProvider.
|
||||
var embeddingCapable = map[string]bool{
|
||||
"openai": true,
|
||||
"openrouter": true,
|
||||
"gemini": true,
|
||||
}
|
||||
|
||||
// autoDetectEmbeddingProvider picks an embedding provider from available API keys.
|
||||
// Priority: primary provider (GOCLAW_PROVIDER) if embedding-capable, then openai → openrouter → gemini.
|
||||
func autoDetectEmbeddingProvider(cfg *config.Config) string {
|
||||
// Prioritize the primary provider if it supports embeddings.
|
||||
primary := cfg.Agents.Defaults.Provider
|
||||
if embeddingCapable[primary] && resolveProviderAPIKey(cfg, primary) != "" {
|
||||
return primary
|
||||
}
|
||||
|
||||
// Fallback: first available embedding-capable key.
|
||||
if cfg.Providers.OpenAI.APIKey != "" {
|
||||
return "openai"
|
||||
}
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
return "openrouter"
|
||||
}
|
||||
if cfg.Providers.Gemini.APIKey != "" {
|
||||
return "gemini"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectProvider finds the first provider with an API key in the environment.
|
||||
func detectProvider(cfg *config.Config) (string, string) {
|
||||
for _, name := range providerPriority {
|
||||
key := resolveProviderAPIKey(cfg, name)
|
||||
if key != "" {
|
||||
return name, key
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// saveCleanConfig saves a minimal config.json without noise (empty providers,
|
||||
// disabled channels, stripped secrets). Only includes sections relevant to
|
||||
// the active configuration so the file serves as clean documentation.
|
||||
// Channels are stored in the DB (channel_instances table),
|
||||
// so they are omitted from config.json to avoid dual-connection.
|
||||
func saveCleanConfig(cfgPath string, cfg *config.Config) error {
|
||||
// Channels are stored in the DB (channel_instances table),
|
||||
// so they are omitted from config.json to avoid dual-connection.
|
||||
channels := make(map[string]interface{})
|
||||
|
||||
// Build tools section.
|
||||
tools := map[string]interface{}{
|
||||
"web": map[string]interface{}{
|
||||
"duckduckgo": map[string]interface{}{
|
||||
"enabled": cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
"max_results": nonZero(cfg.Tools.Web.DuckDuckGo.MaxResults, 5),
|
||||
},
|
||||
},
|
||||
"browser": map[string]interface{}{
|
||||
"enabled": cfg.Tools.Browser.Enabled,
|
||||
"headless": cfg.Tools.Browser.Headless,
|
||||
"remote_url": cfg.Tools.Browser.RemoteURL,
|
||||
},
|
||||
"execApproval": map[string]interface{}{
|
||||
"security": nonEmpty(cfg.Tools.ExecApproval.Security, "full"),
|
||||
"ask": nonEmpty(cfg.Tools.ExecApproval.Ask, "off"),
|
||||
},
|
||||
}
|
||||
|
||||
// Build agents section.
|
||||
agents := map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"workspace": cfg.Agents.Defaults.Workspace,
|
||||
"restrict_to_workspace": cfg.Agents.Defaults.RestrictToWorkspace,
|
||||
"provider": cfg.Agents.Defaults.Provider,
|
||||
"model": cfg.Agents.Defaults.Model,
|
||||
"max_tokens": cfg.Agents.Defaults.MaxTokens,
|
||||
"temperature": cfg.Agents.Defaults.Temperature,
|
||||
"max_tool_iterations": cfg.Agents.Defaults.MaxToolIterations,
|
||||
"context_window": cfg.Agents.Defaults.ContextWindow,
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Agents.Defaults.Subagents != nil {
|
||||
agents["defaults"].(map[string]interface{})["subagents"] = cfg.Agents.Defaults.Subagents
|
||||
}
|
||||
|
||||
if mc := cfg.Agents.Defaults.Memory; mc != nil {
|
||||
mem := map[string]interface{}{
|
||||
"enabled": mc.Enabled == nil || *mc.Enabled,
|
||||
}
|
||||
if mc.EmbeddingProvider != "" {
|
||||
mem["embedding_provider"] = mc.EmbeddingProvider
|
||||
}
|
||||
if mc.EmbeddingModel != "" {
|
||||
mem["embedding_model"] = mc.EmbeddingModel
|
||||
}
|
||||
agents["defaults"].(map[string]interface{})["memory"] = mem
|
||||
}
|
||||
|
||||
// Build gateway section (no token — secret).
|
||||
gateway := map[string]interface{}{
|
||||
"host": cfg.Gateway.Host,
|
||||
"port": cfg.Gateway.Port,
|
||||
"max_message_chars": nonZero(cfg.Gateway.MaxMessageChars, 32000),
|
||||
"rate_limit_rpm": nonZero(cfg.Gateway.RateLimitRPM, 20),
|
||||
"inbound_debounce_ms": nonZero(cfg.Gateway.InboundDebounceMs, 1000),
|
||||
}
|
||||
|
||||
// Build root config map.
|
||||
root := map[string]interface{}{
|
||||
"agents": agents,
|
||||
"gateway": gateway,
|
||||
"tools": tools,
|
||||
}
|
||||
|
||||
if len(channels) > 0 {
|
||||
root["channels"] = channels
|
||||
}
|
||||
|
||||
|
||||
data, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(cfgPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cfgPath, data, 0600)
|
||||
}
|
||||
|
||||
// nonEmpty returns val if non-empty, otherwise fallback.
|
||||
func nonEmpty(val, fallback string) string {
|
||||
if val != "" {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// nonZero returns val if non-zero, otherwise fallback.
|
||||
func nonZero(val, fallback int) int {
|
||||
if val != 0 {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// runClaudeAuthLogin runs `claude auth login` interactively,
|
||||
// connecting stdin/stdout/stderr to the terminal for the browser OAuth flow.
|
||||
func runClaudeAuthLogin(cliPath string) error {
|
||||
fmt.Println(" Opening browser for Claude authentication...")
|
||||
cmd := exec.Command(cliPath, "auth", "login")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import "fmt"
|
||||
|
||||
// promptFeishuConfig runs the Feishu/Lark configuration prompts sequentially.
|
||||
func promptFeishuConfig(appID, appSecret, domain, connMode *string) error {
|
||||
fmt.Println("\n ── Feishu / Lark Configuration ──")
|
||||
|
||||
val, err := promptString("Feishu App ID", "From the Feishu/Lark Open Platform console", *appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*appID = val
|
||||
|
||||
val, err = promptPassword("Feishu App Secret", "Keep this secret safe")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if val != "" {
|
||||
*appSecret = val
|
||||
}
|
||||
|
||||
// API Domain
|
||||
d, err := promptSelect("API Domain", []SelectOption[string]{
|
||||
{"Lark Global (open.larksuite.com)", "lark"},
|
||||
{"Feishu China (open.feishu.cn)", "feishu"},
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*domain = d
|
||||
|
||||
// Connection Mode
|
||||
cm, err := promptSelect("Connection Mode", []SelectOption[string]{
|
||||
{"WebSocket (recommended, no public URL needed)", "websocket"},
|
||||
{"Webhook (requires public URL)", "webhook"},
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*connMode = cm
|
||||
|
||||
return nil
|
||||
}
|
||||
+6
-121
@@ -6,138 +6,23 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// resolveExistingAPIKey tries to find an existing API key from config or env vars.
|
||||
func resolveExistingAPIKey(cfg *config.Config, provider string) string {
|
||||
key := resolveProviderAPIKey(cfg, provider)
|
||||
if key != "" {
|
||||
return key
|
||||
}
|
||||
if pi, ok := providerMap[provider]; ok && pi.envKey != "" {
|
||||
if envKey := os.Getenv(pi.envKey); envKey != "" {
|
||||
return envKey
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyProviderAPIKey stores the API key in the correct config field.
|
||||
func applyProviderAPIKey(cfg *config.Config, provider, key string) {
|
||||
switch provider {
|
||||
case "openrouter":
|
||||
cfg.Providers.OpenRouter.APIKey = key
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic.APIKey = key
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI.APIKey = key
|
||||
case "groq":
|
||||
cfg.Providers.Groq.APIKey = key
|
||||
case "deepseek":
|
||||
cfg.Providers.DeepSeek.APIKey = key
|
||||
case "gemini":
|
||||
cfg.Providers.Gemini.APIKey = key
|
||||
case "mistral":
|
||||
cfg.Providers.Mistral.APIKey = key
|
||||
case "xai":
|
||||
cfg.Providers.XAI.APIKey = key
|
||||
case "minimax":
|
||||
cfg.Providers.MiniMax.APIKey = key
|
||||
case "cohere":
|
||||
cfg.Providers.Cohere.APIKey = key
|
||||
case "perplexity":
|
||||
cfg.Providers.Perplexity.APIKey = key
|
||||
}
|
||||
}
|
||||
|
||||
func onboardGenerateToken(bytes int) string {
|
||||
b := make([]byte, bytes)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func onboardWriteEnvFile(path string, cfg *config.Config, primaryKey, primaryEnvKey string, traceVerbose bool) {
|
||||
// onboardWriteEnvFile writes the minimal .env.local with only the 3 required secrets.
|
||||
func onboardWriteEnvFile(path, postgresDSN, gatewayToken, encryptionKey string) {
|
||||
var lines []string
|
||||
lines = append(lines, "# GoClaw — auto-generated by onboard wizard")
|
||||
lines = append(lines, "# GoClaw — auto-generated by onboard")
|
||||
lines = append(lines, "# Keep this file secret! Add to .gitignore.")
|
||||
lines = append(lines, "")
|
||||
|
||||
if primaryEnvKey != "" && primaryKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("export %s=%s", primaryEnvKey, primaryKey))
|
||||
}
|
||||
|
||||
// Claude CLI config (no API key, just path/model)
|
||||
if cfg.Providers.ClaudeCLI.CLIPath != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_PATH=%s", cfg.Providers.ClaudeCLI.CLIPath))
|
||||
if cfg.Providers.ClaudeCLI.Model != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_MODEL=%s", cfg.Providers.ClaudeCLI.Model))
|
||||
}
|
||||
}
|
||||
|
||||
// Add other configured provider keys (skip if same as primary)
|
||||
addIfSet := func(envKey, value string) {
|
||||
if value != "" && (primaryEnvKey == "" || envKey != primaryEnvKey) {
|
||||
lines = append(lines, fmt.Sprintf("export %s=%s", envKey, value))
|
||||
}
|
||||
}
|
||||
addIfSet("GOCLAW_ANTHROPIC_API_KEY", cfg.Providers.Anthropic.APIKey)
|
||||
addIfSet("GOCLAW_OPENAI_API_KEY", cfg.Providers.OpenAI.APIKey)
|
||||
addIfSet("GOCLAW_OPENROUTER_API_KEY", cfg.Providers.OpenRouter.APIKey)
|
||||
addIfSet("GOCLAW_GROQ_API_KEY", cfg.Providers.Groq.APIKey)
|
||||
addIfSet("GOCLAW_DEEPSEEK_API_KEY", cfg.Providers.DeepSeek.APIKey)
|
||||
addIfSet("GOCLAW_GEMINI_API_KEY", cfg.Providers.Gemini.APIKey)
|
||||
addIfSet("GOCLAW_MISTRAL_API_KEY", cfg.Providers.Mistral.APIKey)
|
||||
addIfSet("GOCLAW_XAI_API_KEY", cfg.Providers.XAI.APIKey)
|
||||
addIfSet("GOCLAW_MINIMAX_API_KEY", cfg.Providers.MiniMax.APIKey)
|
||||
addIfSet("GOCLAW_COHERE_API_KEY", cfg.Providers.Cohere.APIKey)
|
||||
addIfSet("GOCLAW_PERPLEXITY_API_KEY", cfg.Providers.Perplexity.APIKey)
|
||||
|
||||
if cfg.Gateway.Token != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_GATEWAY_TOKEN=%s", cfg.Gateway.Token))
|
||||
}
|
||||
|
||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_TELEGRAM_TOKEN=%s", cfg.Channels.Telegram.Token))
|
||||
}
|
||||
if cfg.Channels.Zalo.Enabled && cfg.Channels.Zalo.Token != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_ZALO_TOKEN=%s", cfg.Channels.Zalo.Token))
|
||||
}
|
||||
if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppSecret != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_FEISHU_APP_ID=%s", cfg.Channels.Feishu.AppID))
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_FEISHU_APP_SECRET=%s", cfg.Channels.Feishu.AppSecret))
|
||||
}
|
||||
|
||||
// Database
|
||||
if cfg.Database.PostgresDSN != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", cfg.Database.PostgresDSN))
|
||||
}
|
||||
|
||||
// Encryption key for API keys in DB
|
||||
if encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY"); encKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encKey))
|
||||
}
|
||||
|
||||
// TTS secrets
|
||||
if cfg.Tts.OpenAI.APIKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_OPENAI_API_KEY=%s", cfg.Tts.OpenAI.APIKey))
|
||||
}
|
||||
if cfg.Tts.ElevenLabs.APIKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_ELEVENLABS_API_KEY=%s", cfg.Tts.ElevenLabs.APIKey))
|
||||
}
|
||||
if cfg.Tts.MiniMax.APIKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_MINIMAX_API_KEY=%s", cfg.Tts.MiniMax.APIKey))
|
||||
}
|
||||
if cfg.Tts.MiniMax.GroupID != "" {
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_MINIMAX_GROUP_ID=%s", cfg.Tts.MiniMax.GroupID))
|
||||
}
|
||||
|
||||
// Verbose tracing
|
||||
if traceVerbose {
|
||||
lines = append(lines, "export GOCLAW_TRACE_VERBOSE=1")
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", postgresDSN))
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_GATEWAY_TOKEN=%s", gatewayToken))
|
||||
lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encryptionKey))
|
||||
lines = append(lines, "")
|
||||
|
||||
content := strings.Join(lines, "\n")
|
||||
|
||||
+20
-273
@@ -2,16 +2,11 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
@@ -29,259 +24,6 @@ func testPostgresConnection(dsn string) error {
|
||||
return db.PingContext(ctx)
|
||||
}
|
||||
|
||||
// seedManagedData inserts providers, default model, and default agent into Postgres
|
||||
// so the gateway has something to work with on first start.
|
||||
// All providers with API keys are seeded (not just the default one).
|
||||
// Idempotent: duplicate entries are skipped on re-run.
|
||||
func seedManagedData(dsn string, cfg *config.Config) error {
|
||||
storeCfg := store.StoreConfig{
|
||||
PostgresDSN: dsn,
|
||||
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
|
||||
}
|
||||
stores, err := pg.NewPGStores(storeCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open PG stores: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Resolve owner: use first GOCLAW_OWNER_IDS entry if set, otherwise "system".
|
||||
ownerID := "system"
|
||||
if len(cfg.Gateway.OwnerIDs) > 0 && cfg.Gateway.OwnerIDs[0] != "" {
|
||||
ownerID = cfg.Gateway.OwnerIDs[0]
|
||||
}
|
||||
|
||||
defaultProvider := cfg.Agents.Defaults.Provider
|
||||
if defaultProvider == "" {
|
||||
defaultProvider = "openrouter"
|
||||
}
|
||||
|
||||
// 1. Seed all providers that have API keys.
|
||||
// Errors are non-fatal per provider (e.g. unique violation on re-run).
|
||||
var seededCount int
|
||||
for _, name := range providerPriority {
|
||||
apiKey := resolveProviderAPIKey(cfg, name)
|
||||
if apiKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
providerType := resolveProviderType(name)
|
||||
|
||||
p := &store.LLMProviderData{
|
||||
Name: name,
|
||||
DisplayName: name,
|
||||
ProviderType: providerType,
|
||||
APIBase: resolveProviderAPIBase(name),
|
||||
APIKey: apiKey,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
if err := stores.Providers.CreateProvider(ctx, p); err != nil {
|
||||
slog.Debug("seed provider skipped (may already exist)", "name", name, "error", err)
|
||||
continue
|
||||
}
|
||||
seededCount++
|
||||
slog.Info("seeded provider", "name", name)
|
||||
}
|
||||
|
||||
if seededCount > 0 {
|
||||
fmt.Printf(" Seeded %d provider(s)\n", seededCount)
|
||||
}
|
||||
|
||||
// 2. Find the default provider's ID from DB (handles both fresh seed and re-run).
|
||||
allProviders, err := stores.Providers.ListProviders(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list providers: %w", err)
|
||||
}
|
||||
|
||||
var defaultProviderID uuid.UUID
|
||||
for _, p := range allProviders {
|
||||
if p.Name == defaultProvider {
|
||||
defaultProviderID = p.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if defaultProviderID == uuid.Nil {
|
||||
return fmt.Errorf("default provider %q not found in DB (no API key?)", defaultProvider)
|
||||
}
|
||||
|
||||
// 3. Resolve default model string (used for agent seed below).
|
||||
modelID := cfg.Agents.Defaults.Model
|
||||
if modelID == "" {
|
||||
modelID = "anthropic/claude-sonnet-4-5-20250929"
|
||||
}
|
||||
|
||||
// 4. Seed default agent (skip if already exists)
|
||||
workspace := config.ExpandHome(cfg.Agents.Defaults.Workspace)
|
||||
agent := &store.AgentData{
|
||||
AgentKey: "default",
|
||||
DisplayName: "Default Agent",
|
||||
OwnerID: ownerID,
|
||||
AgentType: store.AgentTypeOpen,
|
||||
Provider: defaultProvider,
|
||||
Model: modelID,
|
||||
Workspace: workspace,
|
||||
RestrictToWorkspace: true,
|
||||
IsDefault: true,
|
||||
Status: store.AgentStatusActive,
|
||||
SubagentsConfig: json.RawMessage(`{"maxSpawnDepth":1,"maxConcurrent":20}`),
|
||||
}
|
||||
|
||||
if err := stores.Agents.Create(ctx, agent); err != nil {
|
||||
slog.Debug("seed agent skipped (may already exist)", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 5. Seed context files into agent_context_files (only for predefined agents;
|
||||
// open agents get per-user files via SeedUserFiles on first chat)
|
||||
if _, err := bootstrap.SeedToStore(ctx, stores.Agents, agent.ID, agent.AgentType); err != nil {
|
||||
return fmt.Errorf("seed context files: %w", err)
|
||||
}
|
||||
|
||||
// 6. Seed channel instances from env vars (if set)
|
||||
seedChannelInstances(ctx, stores, cfg, agent.ID, ownerID)
|
||||
|
||||
// 7. Seed config_secrets from env vars
|
||||
seedConfigSecrets(ctx, stores, cfg)
|
||||
|
||||
// 8. Seed placeholder providers for UI discoverability
|
||||
seedDefaultPlaceholders(ctx, stores)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedChannelInstances creates channel_instances rows from env-var-provided credentials.
|
||||
// Idempotent: skips if an instance with the same name already exists.
|
||||
func seedChannelInstances(ctx context.Context, stores *store.Stores, cfg *config.Config, defaultAgentID uuid.UUID, ownerID string) {
|
||||
if stores.ChannelInstances == nil {
|
||||
return
|
||||
}
|
||||
|
||||
type seed struct {
|
||||
name string // channel name in the system
|
||||
channelType string
|
||||
display string
|
||||
creds map[string]string
|
||||
config map[string]interface{}
|
||||
}
|
||||
|
||||
var seeds []seed
|
||||
|
||||
// Telegram: use legacy name "telegram" for backward compat with existing session keys.
|
||||
if cfg.Channels.Telegram.Token != "" {
|
||||
tgConfig := map[string]interface{}{
|
||||
"dm_policy": nonEmpty(cfg.Channels.Telegram.DMPolicy, "pairing"),
|
||||
"group_policy": nonEmpty(cfg.Channels.Telegram.GroupPolicy, "pairing"),
|
||||
"reaction_level": nonEmpty(cfg.Channels.Telegram.ReactionLevel, "full"),
|
||||
"history_limit": nonZero(cfg.Channels.Telegram.HistoryLimit, 50),
|
||||
}
|
||||
if cfg.Channels.Telegram.RequireMention != nil {
|
||||
tgConfig["require_mention"] = *cfg.Channels.Telegram.RequireMention
|
||||
}
|
||||
if cfg.Channels.Telegram.MediaMaxBytes > 0 {
|
||||
tgConfig["media_max_bytes"] = cfg.Channels.Telegram.MediaMaxBytes
|
||||
}
|
||||
if cfg.Channels.Telegram.LinkPreview != nil {
|
||||
tgConfig["link_preview"] = *cfg.Channels.Telegram.LinkPreview
|
||||
}
|
||||
if len(cfg.Channels.Telegram.AllowFrom) > 0 {
|
||||
tgConfig["allow_from"] = cfg.Channels.Telegram.AllowFrom
|
||||
}
|
||||
|
||||
seeds = append(seeds, seed{
|
||||
name: "telegram", channelType: "telegram", display: "Telegram Bot",
|
||||
creds: map[string]string{"token": cfg.Channels.Telegram.Token, "proxy": cfg.Channels.Telegram.Proxy},
|
||||
config: tgConfig,
|
||||
})
|
||||
}
|
||||
|
||||
// Other channels: use {type}/default format (no legacy data to preserve).
|
||||
if cfg.Channels.Discord.Token != "" {
|
||||
seeds = append(seeds, seed{
|
||||
name: "discord/default", channelType: "discord", display: "Discord Bot",
|
||||
creds: map[string]string{"token": cfg.Channels.Discord.Token},
|
||||
config: map[string]interface{}{"dm_policy": cfg.Channels.Discord.DMPolicy, "group_policy": cfg.Channels.Discord.GroupPolicy},
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Channels.Feishu.AppID != "" && cfg.Channels.Feishu.AppSecret != "" {
|
||||
seeds = append(seeds, seed{
|
||||
name: "feishu/default", channelType: "feishu", display: "Feishu/Lark Bot",
|
||||
creds: map[string]string{
|
||||
"app_id": cfg.Channels.Feishu.AppID, "app_secret": cfg.Channels.Feishu.AppSecret,
|
||||
"encrypt_key": cfg.Channels.Feishu.EncryptKey, "verification_token": cfg.Channels.Feishu.VerificationToken,
|
||||
},
|
||||
config: map[string]interface{}{"dm_policy": cfg.Channels.Feishu.DMPolicy, "domain": cfg.Channels.Feishu.Domain},
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Channels.Zalo.Token != "" {
|
||||
seeds = append(seeds, seed{
|
||||
name: "zalo_oa/default", channelType: "zalo_oa", display: "Zalo OA",
|
||||
creds: map[string]string{"token": cfg.Channels.Zalo.Token, "webhook_secret": cfg.Channels.Zalo.WebhookSecret},
|
||||
config: map[string]interface{}{"dm_policy": cfg.Channels.Zalo.DMPolicy},
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Channels.WhatsApp.BridgeURL != "" {
|
||||
seeds = append(seeds, seed{
|
||||
name: "whatsapp/default", channelType: "whatsapp", display: "WhatsApp",
|
||||
creds: map[string]string{"bridge_url": cfg.Channels.WhatsApp.BridgeURL},
|
||||
config: map[string]interface{}{"dm_policy": cfg.Channels.WhatsApp.DMPolicy, "group_policy": cfg.Channels.WhatsApp.GroupPolicy},
|
||||
})
|
||||
}
|
||||
|
||||
seeded := 0
|
||||
for _, s := range seeds {
|
||||
credsJSON, _ := json.Marshal(s.creds)
|
||||
cfgJSON, _ := json.Marshal(s.config)
|
||||
|
||||
inst := &store.ChannelInstanceData{
|
||||
Name: s.name,
|
||||
DisplayName: s.display,
|
||||
ChannelType: s.channelType,
|
||||
AgentID: defaultAgentID,
|
||||
Credentials: credsJSON,
|
||||
Config: cfgJSON,
|
||||
Enabled: true,
|
||||
CreatedBy: ownerID,
|
||||
}
|
||||
|
||||
if err := stores.ChannelInstances.Create(ctx, inst); err != nil {
|
||||
slog.Debug("seed channel instance skipped (may already exist)", "name", s.name, "error", err)
|
||||
continue
|
||||
}
|
||||
seeded++
|
||||
slog.Info("seeded channel instance", "name", s.name, "type", s.channelType)
|
||||
}
|
||||
|
||||
if seeded > 0 {
|
||||
fmt.Printf(" Seeded %d channel instance(s)\n", seeded)
|
||||
}
|
||||
}
|
||||
|
||||
// seedConfigSecrets saves non-LLM/non-channel secrets to the config_secrets table.
|
||||
// These are secrets that don't belong in llm_providers or channel_instances tables.
|
||||
func seedConfigSecrets(ctx context.Context, stores *store.Stores, cfg *config.Config) {
|
||||
if stores.ConfigSecrets == nil {
|
||||
return
|
||||
}
|
||||
|
||||
secrets := cfg.ExtractDBSecrets()
|
||||
seeded := 0
|
||||
for key, value := range secrets {
|
||||
if err := stores.ConfigSecrets.Set(ctx, key, value); err != nil {
|
||||
slog.Debug("seed config secret failed", "key", key, "error", err)
|
||||
continue
|
||||
}
|
||||
seeded++
|
||||
}
|
||||
|
||||
if seeded > 0 {
|
||||
slog.Info("seeded config secrets", "count", seeded)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultPlaceholderProviders defines disabled placeholder providers seeded for
|
||||
// UI discoverability. Users can later enable and configure them via the dashboard.
|
||||
var defaultPlaceholderProviders = []store.LLMProviderData{
|
||||
@@ -291,21 +33,28 @@ var defaultPlaceholderProviders = []store.LLMProviderData{
|
||||
{Name: "alicloud-sub", DisplayName: "AliCloud Sub", ProviderType: store.ProviderBailian, APIBase: "https://coding-intl.dashscope.aliyuncs.com/v1", Enabled: false},
|
||||
}
|
||||
|
||||
// seedDefaultPlaceholders inserts disabled placeholder providers so they
|
||||
// appear in the UI for easy configuration. Idempotent: UNIQUE(name) constraint
|
||||
// skips duplicates, and providers whose api_base already exists are skipped
|
||||
// to avoid overwriting user-configured entries.
|
||||
func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) {
|
||||
if stores.Providers == nil {
|
||||
return
|
||||
// seedOnboardPlaceholders opens a PG store and seeds disabled placeholder providers
|
||||
// so they appear in the UI for easy configuration.
|
||||
func seedOnboardPlaceholders(dsn string) error {
|
||||
storeCfg := store.StoreConfig{
|
||||
PostgresDSN: dsn,
|
||||
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
|
||||
}
|
||||
stores, err := pg.NewPGStores(storeCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open PG stores: %w", err)
|
||||
}
|
||||
|
||||
// Build a set of existing api_base values to avoid seeding a placeholder
|
||||
// when a user-configured provider already uses that base URL.
|
||||
if stores.Providers == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Build a set of existing api_base values to avoid overwriting user-configured entries.
|
||||
existing, err := stores.Providers.ListProviders(ctx)
|
||||
if err != nil {
|
||||
slog.Debug("seedDefaultPlaceholders: list providers failed", "error", err)
|
||||
return
|
||||
return fmt.Errorf("list providers: %w", err)
|
||||
}
|
||||
existingBases := make(map[string]bool, len(existing))
|
||||
for _, p := range existing {
|
||||
@@ -316,11 +65,9 @@ func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) {
|
||||
|
||||
seeded := 0
|
||||
for _, ph := range defaultPlaceholderProviders {
|
||||
// Skip if a provider with this api_base already exists
|
||||
if ph.APIBase != "" && existingBases[ph.APIBase] {
|
||||
continue
|
||||
}
|
||||
|
||||
p := ph // copy
|
||||
if err := stores.Providers.CreateProvider(ctx, &p); err != nil {
|
||||
slog.Debug("seed placeholder skipped (may already exist)", "name", ph.Name, "error", err)
|
||||
@@ -330,7 +77,7 @@ func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) {
|
||||
}
|
||||
|
||||
if seeded > 0 {
|
||||
fmt.Printf(" Seeded %d placeholder provider(s)\n", seeded)
|
||||
slog.Info("seeded placeholder providers", "count", seeded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// topOpenRouterProviders is a curated whitelist of the best model providers on OpenRouter.
|
||||
var topOpenRouterProviders = map[string]bool{
|
||||
"anthropic": true,
|
||||
"openai": true,
|
||||
"google": true,
|
||||
"mistralai": true,
|
||||
"deepseek": true,
|
||||
"meta-llama": true,
|
||||
"qwen": true,
|
||||
"x-ai": true,
|
||||
"z-ai": true,
|
||||
"nvidia": true,
|
||||
"moonshotai": true,
|
||||
"minimax": true,
|
||||
"allenai": true,
|
||||
"cohere": true,
|
||||
"perplexity": true,
|
||||
"amazon": true,
|
||||
"microsoft": true,
|
||||
"ai21": true,
|
||||
"nousresearch": true,
|
||||
"baidu": true,
|
||||
}
|
||||
|
||||
type openRouterModel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ContextLength int `json:"context_length"`
|
||||
}
|
||||
|
||||
func fetchOpenRouterModels() ([]openRouterModel, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get("https://openrouter.ai/api/v1/models")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []openRouterModel `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(result.Data, func(i, j int) bool {
|
||||
return result.Data[i].ID < result.Data[j].ID
|
||||
})
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
func filterTopProviderModels(models []openRouterModel) []openRouterModel {
|
||||
var filtered []openRouterModel
|
||||
for _, m := range models {
|
||||
parts := strings.SplitN(m.ID, "/", 2)
|
||||
if len(parts) == 2 && topOpenRouterProviders[parts[0]] {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func formatCtx(ctxLen int) string {
|
||||
if ctxLen >= 1_000_000 {
|
||||
return fmt.Sprintf("%.0fM ctx", float64(ctxLen)/1_000_000)
|
||||
}
|
||||
if ctxLen >= 1000 {
|
||||
return fmt.Sprintf("%dK ctx", ctxLen/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d ctx", ctxLen)
|
||||
}
|
||||
|
||||
// fallbackModels used when API is unreachable.
|
||||
var fallbackModels = []openRouterModel{
|
||||
{ID: "anthropic/claude-sonnet-4-5-20250929", Name: "Claude Sonnet 4.5", ContextLength: 200000},
|
||||
{ID: "openai/gpt-4o", Name: "GPT-4o", ContextLength: 128000},
|
||||
{ID: "google/gemini-2.0-flash-001", Name: "Gemini 2.0 Flash", ContextLength: 1048576},
|
||||
{ID: "deepseek/deepseek-chat-v3-0324", Name: "DeepSeek V3", ContextLength: 131072},
|
||||
}
|
||||
|
||||
// buildOpenRouterModelOptions builds select options from OpenRouter models.
|
||||
// Pre-fetches from API; uses fallback on failure.
|
||||
func buildOpenRouterModelOptions() []SelectOption[string] {
|
||||
models, err := fetchOpenRouterModels()
|
||||
if err != nil || len(models) == 0 {
|
||||
models = fallbackModels
|
||||
} else {
|
||||
models = filterTopProviderModels(models)
|
||||
if len(models) == 0 {
|
||||
models = fallbackModels
|
||||
}
|
||||
}
|
||||
|
||||
options := make([]SelectOption[string], 0, len(models)+1)
|
||||
for _, m := range models {
|
||||
label := fmt.Sprintf("%-50s (%s)", m.ID, formatCtx(m.ContextLength))
|
||||
options = append(options, SelectOption[string]{Label: label, Value: m.ID})
|
||||
}
|
||||
options = append(options, SelectOption[string]{Label: "Enter custom model ID...", Value: "__custom__"})
|
||||
return options
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// resolveProviderAPIKey extracts the API key for a provider from the config.
|
||||
func resolveProviderAPIKey(cfg *config.Config, providerName string) string {
|
||||
switch providerName {
|
||||
case "openrouter":
|
||||
return cfg.Providers.OpenRouter.APIKey
|
||||
case "anthropic":
|
||||
return cfg.Providers.Anthropic.APIKey
|
||||
case "openai":
|
||||
return cfg.Providers.OpenAI.APIKey
|
||||
case "groq":
|
||||
return cfg.Providers.Groq.APIKey
|
||||
case "deepseek":
|
||||
return cfg.Providers.DeepSeek.APIKey
|
||||
case "gemini":
|
||||
return cfg.Providers.Gemini.APIKey
|
||||
case "mistral":
|
||||
return cfg.Providers.Mistral.APIKey
|
||||
case "xai":
|
||||
return cfg.Providers.XAI.APIKey
|
||||
case "minimax":
|
||||
return cfg.Providers.MiniMax.APIKey
|
||||
case "cohere":
|
||||
return cfg.Providers.Cohere.APIKey
|
||||
case "perplexity":
|
||||
return cfg.Providers.Perplexity.APIKey
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// resolveProviderType maps a provider name to its store.Provider* type constant.
|
||||
func resolveProviderType(name string) string {
|
||||
switch name {
|
||||
case "anthropic":
|
||||
return store.ProviderAnthropicNative
|
||||
case "gemini":
|
||||
return store.ProviderGeminiNative
|
||||
case "minimax":
|
||||
return store.ProviderMiniMax
|
||||
case "openrouter":
|
||||
return store.ProviderOpenRouter
|
||||
case "groq":
|
||||
return store.ProviderGroq
|
||||
case "deepseek":
|
||||
return store.ProviderDeepSeek
|
||||
case "mistral":
|
||||
return store.ProviderMistral
|
||||
case "xai":
|
||||
return store.ProviderXAI
|
||||
case "cohere":
|
||||
return store.ProviderCohere
|
||||
case "perplexity":
|
||||
return store.ProviderPerplexity
|
||||
case "yescale":
|
||||
return store.ProviderYesScale
|
||||
default:
|
||||
return store.ProviderOpenAICompat
|
||||
}
|
||||
}
|
||||
|
||||
// resolveProviderAPIBase returns the default API base URL for known providers.
|
||||
func resolveProviderAPIBase(providerName string) string {
|
||||
switch providerName {
|
||||
case "openrouter":
|
||||
return "https://openrouter.ai/api/v1"
|
||||
case "anthropic":
|
||||
return "https://api.anthropic.com/v1"
|
||||
case "openai":
|
||||
return "https://api.openai.com/v1"
|
||||
case "groq":
|
||||
return "https://api.groq.com/openai/v1"
|
||||
case "deepseek":
|
||||
return "https://api.deepseek.com/v1"
|
||||
case "gemini":
|
||||
return "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
case "mistral":
|
||||
return "https://api.mistral.ai/v1"
|
||||
case "xai":
|
||||
return "https://api.x.ai/v1"
|
||||
case "minimax":
|
||||
return "https://api.minimax.io/v1"
|
||||
case "cohere":
|
||||
return "https://api.cohere.com/v2"
|
||||
case "perplexity":
|
||||
return "https://api.perplexity.ai"
|
||||
case "yescale":
|
||||
return "https://api.yescale.one/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import "fmt"
|
||||
|
||||
// promptTTSConfig runs the TTS configuration prompts sequentially.
|
||||
// Returns early on any error (e.g. user pressed Ctrl+C).
|
||||
func promptTTSConfig(ttsProvider, ttsAPIKey, ttsGroupID, ttsAutoMode *string) error {
|
||||
// TTS provider
|
||||
provider, err := promptSelect("TTS Provider (Text-to-Speech for agent replies)", []SelectOption[string]{
|
||||
{"None (disabled)", "none"},
|
||||
{"OpenAI (gpt-4o-mini-tts, alloy voice)", "openai"},
|
||||
{"ElevenLabs (high quality, multilingual)", "elevenlabs"},
|
||||
{"MiniMax (speech-02-hd, 300+ voices)", "minimax"},
|
||||
{"Edge (free Microsoft Edge TTS, no API key)", "edge"},
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*ttsProvider = provider
|
||||
|
||||
if *ttsProvider == "none" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TTS auto-apply mode
|
||||
autoMode, err := promptSelect("TTS auto-apply mode", []SelectOption[string]{
|
||||
{"Off (agent can use tts tool manually)", "off"},
|
||||
{"Always (all replies get audio)", "always"},
|
||||
{"Inbound (only when user sends voice/audio)", "inbound"},
|
||||
{"Tagged (only when reply has [[tts]] tag)", "tagged"},
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*ttsAutoMode = autoMode
|
||||
|
||||
// API key (not needed for edge)
|
||||
if *ttsProvider != "edge" {
|
||||
key, err := promptPassword("TTS API Key", "Leave empty to reuse your chat provider's API key (if same provider)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*ttsAPIKey = key
|
||||
}
|
||||
|
||||
// MiniMax Group ID
|
||||
if *ttsProvider == "minimax" {
|
||||
groupID, err := promptString("MiniMax Group ID", "Only needed for MiniMax TTS", *ttsGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*ttsGroupID = groupID
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
)
|
||||
|
||||
// providerVerifyError holds the result of a provider connectivity probe.
|
||||
type providerVerifyError struct {
|
||||
fatal bool // true = bad credentials, block startup
|
||||
message string // human-readable description
|
||||
}
|
||||
|
||||
func (e *providerVerifyError) Error() string { return e.message }
|
||||
|
||||
// newProviderForVerify instantiates a temporary provider for key verification.
|
||||
// Mirrors the logic in registerProviders (gateway_providers.go) so auth headers,
|
||||
// base URL overrides, and custom chat paths are handled correctly.
|
||||
func newProviderForVerify(cfg *config.Config, name string) providers.Provider {
|
||||
apiKey := resolveProviderAPIKey(cfg, name)
|
||||
apiBase := resolveProviderAPIBase(name)
|
||||
|
||||
switch name {
|
||||
case "anthropic":
|
||||
if cfg.Providers.Anthropic.APIBase != "" {
|
||||
apiBase = cfg.Providers.Anthropic.APIBase
|
||||
}
|
||||
return providers.NewAnthropicProvider(apiKey, providers.WithAnthropicBaseURL(apiBase))
|
||||
case "dashscope":
|
||||
if cfg.Providers.DashScope.APIBase != "" {
|
||||
apiBase = cfg.Providers.DashScope.APIBase
|
||||
}
|
||||
return providers.NewDashScopeProvider(apiKey, apiBase, "")
|
||||
case "minimax":
|
||||
return providers.NewOpenAIProvider(name, apiKey, apiBase, "").WithChatPath("/text/chatcompletion_v2")
|
||||
case "openai":
|
||||
if cfg.Providers.OpenAI.APIBase != "" {
|
||||
apiBase = cfg.Providers.OpenAI.APIBase
|
||||
}
|
||||
return providers.NewOpenAIProvider(name, apiKey, apiBase, "")
|
||||
case "bailian":
|
||||
if cfg.Providers.Bailian.APIBase != "" {
|
||||
apiBase = cfg.Providers.Bailian.APIBase
|
||||
}
|
||||
return providers.NewOpenAIProvider(name, apiKey, apiBase, "")
|
||||
default:
|
||||
return providers.NewOpenAIProvider(name, apiKey, apiBase, "")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyProviderConnectivity checks whether a provider's API key is valid by
|
||||
// sending a minimal Chat request through the provider layer. This reuses the
|
||||
// same auth headers, base URLs, and HTTP client as the real provider calls.
|
||||
//
|
||||
// Strategy: provider.Chat() with message "hi", max_tokens=1.
|
||||
// - 401/403 HTTPError → invalid API key (fatal)
|
||||
// - Any other error → non-fatal warning (transient/config issue)
|
||||
// - Success → key is valid
|
||||
func verifyProviderConnectivity(cfg *config.Config, providerName string) *providerVerifyError {
|
||||
apiKey := resolveProviderAPIKey(cfg, providerName)
|
||||
if apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
apiBase := resolveProviderAPIBase(providerName)
|
||||
if apiBase == "" {
|
||||
return nil // custom/unknown provider — skip
|
||||
}
|
||||
|
||||
prov := newProviderForVerify(cfg, providerName)
|
||||
|
||||
model := ""
|
||||
if pi, ok := providerMap[providerName]; ok {
|
||||
model = pi.modelHint
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := prov.Chat(ctx, providers.ChatRequest{
|
||||
Messages: []providers.Message{{Role: "user", Content: "hi"}},
|
||||
Model: model,
|
||||
Options: map[string]interface{}{"max_tokens": 1},
|
||||
})
|
||||
if err != nil {
|
||||
var httpErr *providers.HTTPError
|
||||
if errors.As(err, &httpErr) && (httpErr.Status == 401 || httpErr.Status == 403) {
|
||||
return &providerVerifyError{
|
||||
fatal: true,
|
||||
message: fmt.Sprintf("%s returned %d — invalid API key", providerName, httpErr.Status),
|
||||
}
|
||||
}
|
||||
// Non-auth errors: transient network issue, bad model, rate limit, etc.
|
||||
return &providerVerifyError{
|
||||
fatal: false,
|
||||
message: fmt.Sprintf("%s: %s", providerName, friendlyProviderError(err)),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyAllProviders checks connectivity for every provider that has an API key.
|
||||
// Only the primary provider's auth failure is fatal (blocks bootstrap).
|
||||
// Secondary provider failures are logged as warnings.
|
||||
func verifyAllProviders(cfg *config.Config, primaryProvider string) []string {
|
||||
var fatalErrors []string
|
||||
|
||||
for _, name := range providerPriority {
|
||||
apiKey := resolveProviderAPIKey(cfg, name)
|
||||
if apiKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
verr := verifyProviderConnectivity(cfg, name)
|
||||
if verr == nil {
|
||||
slog.Info("provider connectivity verified", "provider", name)
|
||||
fmt.Printf(" %s: OK\n", name)
|
||||
continue
|
||||
}
|
||||
|
||||
if verr.fatal && name == primaryProvider {
|
||||
slog.Error("primary provider key invalid", "provider", name, "error", verr.message)
|
||||
fmt.Printf(" %s: FAILED — %s\n", name, verr.message)
|
||||
fatalErrors = append(fatalErrors, fmt.Sprintf("%s: %s", name, verr.message))
|
||||
} else if verr.fatal {
|
||||
slog.Warn("secondary provider key invalid (continuing)", "provider", name, "error", verr.message)
|
||||
fmt.Printf(" %s: WARNING — %s (non-primary, skipping)\n", name, verr.message)
|
||||
} else {
|
||||
slog.Warn("provider connectivity warning", "provider", name, "warning", verr.message)
|
||||
fmt.Printf(" %s: WARNING — %s\n", name, verr.message)
|
||||
}
|
||||
}
|
||||
|
||||
return fatalErrors
|
||||
}
|
||||
|
||||
// friendlyProviderError extracts a human-readable message from provider errors.
|
||||
func friendlyProviderError(err error) string {
|
||||
msg := err.Error()
|
||||
|
||||
// Try to extract "message" field from embedded JSON error blobs.
|
||||
if idx := strings.Index(msg, `"message"`); idx >= 0 {
|
||||
rest := msg[idx:]
|
||||
if start := strings.Index(rest, `:`); start >= 0 {
|
||||
rest = strings.TrimLeft(rest[start+1:], " ")
|
||||
if len(rest) > 0 && rest[0] == '"' {
|
||||
rest = rest[1:]
|
||||
if end := strings.Index(rest, `"`); end >= 0 && rest[:end] != "" {
|
||||
return rest[:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip "HTTP NNN: provider: " prefix.
|
||||
if idx := strings.LastIndex(msg, ": "); idx >= 0 && idx < len(msg)-2 {
|
||||
suffix := msg[idx+2:]
|
||||
if strings.HasPrefix(suffix, "{") {
|
||||
return "request rejected by provider"
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import "fmt"
|
||||
|
||||
// promptZaloConfig runs the Zalo OA Bot configuration prompts sequentially.
|
||||
func promptZaloConfig(zaloToken, zaloDMPolicy *string) error {
|
||||
fmt.Println("\n ── Zalo OA Configuration ──")
|
||||
|
||||
val, err := promptPassword("Zalo Bot API Token", "From the Zalo OA developer dashboard")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if val != "" {
|
||||
*zaloToken = val
|
||||
}
|
||||
|
||||
// DM Policy
|
||||
policy, err := promptSelect("Zalo DM Policy — How to handle direct messages", []SelectOption[string]{
|
||||
{"Pairing (require approval code)", "pairing"},
|
||||
{"Open (anyone can chat)", "open"},
|
||||
{"Allowlist (only allow_from IDs)", "allowlist"},
|
||||
{"Disabled (reject all DMs)", "disabled"},
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*zaloDMPolicy = policy
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user