mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +00:00
Multi-agent AI gateway with WebSocket RPC, HTTP API, and messaging channel integrations. Go port of OpenClaw with multi-tenant PostgreSQL, per-user isolation, security hardening, and production observability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
339 lines
10 KiB
Go
339 lines
10 KiB
Go
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"
|
|
)
|
|
|
|
// testPostgresConnection verifies connectivity to Postgres with a 5s timeout.
|
|
func testPostgresConnection(dsn string) error {
|
|
db, err := pg.OpenDB(dsn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
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,
|
|
Mode: "managed",
|
|
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()
|
|
|
|
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 := "openai_compat"
|
|
if name == "anthropic" {
|
|
providerType = "anthropic_native"
|
|
}
|
|
|
|
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: "system",
|
|
AgentType: "open",
|
|
Provider: defaultProvider,
|
|
Model: modelID,
|
|
Workspace: workspace,
|
|
IsDefault: true,
|
|
Status: "active",
|
|
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)
|
|
|
|
// 7. Seed config_secrets from env vars
|
|
seedConfigSecrets(ctx, stores, cfg)
|
|
|
|
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) {
|
|
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"),
|
|
"stream_mode": nonEmpty(cfg.Channels.Telegram.StreamMode, "none"),
|
|
"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: "system",
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
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"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|