picoclaw/pkg/config/config.go
liugangjian 3ea526c341 feat: add comprehensive config validation at startup
- Add Validate() method to check all config fields, formats, and inter-dependencies
- Add validation for all major config areas: agents, channels, gateway, tools, model_list, heartbeat, devices
- Integrate validation into LoadConfig() to catch errors at startup
- Add custom isValidName() function to validate identifiers
- Update AgentDefaults with missing MaxMediaSize field
- Add tests for config validation functionality

This adds a robust validation layer that will catch misconfigurations
before runtime, preventing many potential configuration-related bugs

🤖 AI Assisted - Human designed the solution, AI helped implement it
2026-03-04 21:00:05 +08:00

1372 lines
53 KiB
Go

package config
import (
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
"sync/atomic"
"time"
"github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// rrCounter is a global counter for round-robin load balancing across models.
var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// Try []string first
var ss []string
if err := json.Unmarshal(data, &ss); err == nil {
*f = ss
return nil
}
// Try []interface{} to handle mixed types
var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
result := make([]string, 0, len(raw))
for _, v := range raw {
switch val := v.(type) {
case string:
result = append(result, val)
case float64:
result = append(result, fmt.Sprintf("%.0f", val))
default:
result = append(result, fmt.Sprintf("%v", val))
}
}
*f = result
return nil
}
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers,omitempty"`
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
}
// MarshalJSON implements custom JSON marshaling for Config
// to omit providers section when empty and session when empty
func (c Config) MarshalJSON() ([]byte, error) {
type Alias Config
aux := &struct {
Providers *ProvidersConfig `json:"providers,omitempty"`
Session *SessionConfig `json:"session,omitempty"`
*Alias
}{
Alias: (*Alias)(&c),
}
// Only include providers if not empty
if !c.Providers.IsEmpty() {
aux.Providers = &c.Providers
}
// Only include session if not empty
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
aux.Session = &c.Session
}
return json.Marshal(aux)
}
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
List []AgentConfig `json:"list,omitempty"`
}
// AgentModelConfig supports both string and structured model config.
// String format: "gpt-4" (just primary, no fallbacks)
// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
type AgentModelConfig struct {
Primary string `json:"primary,omitempty"`
Fallbacks []string `json:"fallbacks,omitempty"`
}
func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
m.Primary = s
m.Fallbacks = nil
return nil
}
type raw struct {
Primary string `json:"primary"`
Fallbacks []string `json:"fallbacks"`
}
var r raw
if err := json.Unmarshal(data, &r); err != nil {
return err
}
m.Primary = r.Primary
m.Fallbacks = r.Fallbacks
return nil
}
func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
if len(m.Fallbacks) == 0 && m.Primary != "" {
return json.Marshal(m.Primary)
}
type raw struct {
Primary string `json:"primary,omitempty"`
Fallbacks []string `json:"fallbacks,omitempty"`
}
return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
}
type AgentConfig struct {
ID string `json:"id"`
Default bool `json:"default,omitempty"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
Skills []string `json:"skills,omitempty"`
Subagents *SubagentsConfig `json:"subagents,omitempty"`
}
type SubagentsConfig struct {
AllowAgents []string `json:"allow_agents,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
}
type PeerMatch struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
type BindingMatch struct {
Channel string `json:"channel"`
AccountID string `json:"account_id,omitempty"`
Peer *PeerMatch `json:"peer,omitempty"`
GuildID string `json:"guild_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
}
type AgentBinding struct {
AgentID string `json:"agent_id"`
Match BindingMatch `json:"match"`
}
type SessionConfig struct {
DMScope string `json:"dm_scope,omitempty"`
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
}
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
// Cache settings
CacheEnabled bool `json:"cache_enabled" env:"PICOCLAW_AGENTS_DEFAULTS_CACHE_ENABLED"`
CacheType string `json:"cache_type" env:"PICOCLAW_AGENTS_DEFAULTS_CACHE_TYPE"`
CacheTTL time.Duration `json:"cache_ttl" env:"PICOCLAW_AGENTS_DEFAULTS_CACHE_TTL"`
CacheConfig map[string]string `json:"cache_config" env:"PICOCLAW_AGENTS_DEFAULTS_CACHE_CONFIG"`
}
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
func (d *AgentDefaults) GetMaxMediaSize() int {
if d.MaxMediaSize > 0 {
return d.MaxMediaSize
}
return DefaultMaxMediaSize
}
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
if d.ModelName != "" {
return d.ModelName
}
return d.Model
}
type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"`
Feishu FeishuConfig `json:"feishu"`
Discord DiscordConfig `json:"discord"`
MaixCam MaixCamConfig `json:"maixcam"`
QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
type GroupTriggerConfig struct {
MentionOnly bool `json:"mention_only,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
}
// TypingConfig controls typing indicator behavior (Phase 10).
type TypingConfig struct {
Enabled bool `json:"enabled,omitempty"`
}
// PlaceholderConfig controls placeholder message behavior (Phase 10).
type PlaceholderConfig struct {
Enabled bool `json:"enabled,omitempty"`
Text string `json:"text,omitempty"`
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
}
type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
}
type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
}
type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}
type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
}
type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
}
type OneBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
}
type WeComConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
}
type WeComAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
}
type WeComAIBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
}
type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
AllowOrigins []string `json:"allow_origins,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
WriteTimeout int `json:"write_timeout,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type DevicesConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"`
LiteLLM ProviderConfig `json:"litellm"`
OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"`
Zhipu ProviderConfig `json:"zhipu"`
VLLM ProviderConfig `json:"vllm"`
Gemini ProviderConfig `json:"gemini"`
Nvidia ProviderConfig `json:"nvidia"`
Ollama ProviderConfig `json:"ollama"`
Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
Cerebras ProviderConfig `json:"cerebras"`
VolcEngine ProviderConfig `json:"volcengine"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
Mistral ProviderConfig `json:"mistral"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
// Note: WebSearch is an optimization option and doesn't count as "non-empty"
func (p ProvidersConfig) IsEmpty() bool {
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
// to omit the entire section when empty
func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
if p.IsEmpty() {
return []byte("null"), nil
}
type Alias ProvidersConfig
return json.Marshal((*Alias)(&p))
}
type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
ProviderConfig
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
}
// ModelConfig represents a model-centric provider configuration.
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
// The model field uses protocol prefix format: [protocol/]model-identifier
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
// Default protocol is "openai" if no prefix is specified.
type ModelConfig struct {
// Required fields
ModelName string `json:"model_name"` // User-facing alias for the model
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
// HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL
APIKey string `json:"api_key"` // API authentication key
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
// Special providers (CLI-based, OAuth, etc.)
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
// Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
RequestTimeout int `json:"request_timeout,omitempty"`
}
// Validate checks if the ModelConfig has all required fields.
func (c *ModelConfig) Validate() error {
if c.ModelName == "" {
return fmt.Errorf("model_name is required")
}
if c.Model == "" {
return fmt.Errorf("model is required")
}
return nil
}
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
}
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type TavilyConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
type GLMSearchConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
// SearchEngine specifies the search backend: "search_std" (default),
// "search_pro", "search_pro_sogou", or "search_pro_quark".
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
}
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
GLMSearch GLMSearchConfig `json:"glm_search"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
}
type CronToolsConfig struct {
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
}
type ExecConfig struct {
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
}
type MediaCleanupConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
}
type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"`
}
type SkillsToolsConfig struct {
Registries SkillsRegistriesConfig `json:"registries"`
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
SearchCache SearchCacheConfig `json:"search_cache"`
}
type SearchCacheConfig struct {
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
}
type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub"`
}
type ClawHubRegistryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
// MCPServerConfig defines configuration for a single MCP server
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
Enabled bool `json:"enabled"`
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
Command string `json:"command"`
// Args are the arguments to pass to the command
Args []string `json:"args,omitempty"`
// Env are environment variables to set for the server process (stdio only)
Env map[string]string `json:"env,omitempty"`
// EnvFile is the path to a file containing environment variables (stdio only)
EnvFile string `json:"env_file,omitempty"`
// Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
Type string `json:"type,omitempty"`
// URL is used for SSE/HTTP transport
URL string `json:"url,omitempty"`
// Headers are HTTP headers to send with requests (sse/http only)
Headers map[string]string `json:"headers,omitempty"`
}
// MCPConfig defines configuration for all MCP servers
type MCPConfig struct {
// Enabled globally enables/disables MCP integration
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MCP_ENABLED"`
// Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
// Pre-scan the JSON to check how many model_list entries the user provided.
// Go's JSON decoder reuses existing slice backing-array elements rather than
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
// would silently inherit values from the DefaultConfig template at the same
// index position. We only reset cfg.ModelList when the user actually provides
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
var tmp Config
if err := json.Unmarshal(data, &tmp); err != nil {
return nil, err
}
if len(tmp.ModelList) > 0 {
cfg.ModelList = nil
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
if err := env.Parse(cfg); err != nil {
return nil, err
}
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
}
// Validate model_list for uniqueness and required fields
if err := cfg.ValidateModelList(); err != nil {
return nil, err
}
// Run comprehensive validation on the entire config
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
}
if err := cfg.Validate(); err != nil {
}
// Run comprehensive validation on the entire config
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
}
return cfg, nil
return cfg, nil
}
func (c *Config) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
c.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
func SaveConfig(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
// Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
return expandHome(c.Agents.Defaults.Workspace)
}
func (c *Config) GetAPIKey() string {
if c.Providers.OpenRouter.APIKey != "" {
return c.Providers.OpenRouter.APIKey
}
if c.Providers.Anthropic.APIKey != "" {
return c.Providers.Anthropic.APIKey
}
if c.Providers.OpenAI.APIKey != "" {
return c.Providers.OpenAI.APIKey
}
if c.Providers.Gemini.APIKey != "" {
return c.Providers.Gemini.APIKey
}
if c.Providers.Zhipu.APIKey != "" {
return c.Providers.Zhipu.APIKey
}
if c.Providers.Groq.APIKey != "" {
return c.Providers.Groq.APIKey
}
if c.Providers.VLLM.APIKey != "" {
return c.Providers.VLLM.APIKey
}
if c.Providers.ShengSuanYun.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey
}
if c.Providers.Cerebras.APIKey != "" {
return c.Providers.Cerebras.APIKey
}
return ""
}
func (c *Config) GetAPIBase() string {
if c.Providers.OpenRouter.APIKey != "" {
if c.Providers.OpenRouter.APIBase != "" {
return c.Providers.OpenRouter.APIBase
}
return "https://openrouter.ai/api/v1"
}
if c.Providers.Zhipu.APIKey != "" {
return c.Providers.Zhipu.APIBase
}
if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
return c.Providers.VLLM.APIBase
}
return ""
}
func expandHome(path string) string {
if path == "" {
return path
}
if path[0] == '~' {
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {
return home + path[1:]
}
return home
}
return path
}
// GetModelConfig returns the ModelConfig for the given model name.
// If multiple configs exist with the same model_name, it uses round-robin
// selection for load balancing. Returns an error if the model is not found.
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
matches := c.findMatches(modelName)
if len(matches) == 0 {
return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
}
if len(matches) == 1 {
return &matches[0], nil
}
// Multiple configs - use round-robin for load balancing
idx := rrCounter.Add(1) % uint64(len(matches))
return &matches[idx], nil
}
// findMatches finds all ModelConfig entries with the given model_name.
func (c *Config) findMatches(modelName string) []ModelConfig {
var matches []ModelConfig
for i := range c.ModelList {
if c.ModelList[i].ModelName == modelName {
matches = append(matches, c.ModelList[i])
}
}
return matches
}
// HasProvidersConfig checks if any provider in the old providers config has configuration.
func (c *Config) HasProvidersConfig() bool {
return !c.Providers.IsEmpty()
}
// ValidateModelList validates all ModelConfig entries in the model_list.
// It checks that each model config is valid.
// Note: Multiple entries with the same model_name are allowed for load balancing.
func (c *Config) ValidateModelList() error {
for i := range c.ModelList {
if err := c.ModelList[i].Validate(); err != nil {
return fmt.Errorf("model_list[%d]: %w", i, err)
}
}
return nil
}
// Validate performs comprehensive validation of the Config structure.
// It checks all required fields, formats, and interdependencies.
func (c *Config) Validate() error {
if err := c.validateAgents(); err != nil {
return fmt.Errorf("agents config validation failed: %w", err)
}
if err := c.validateChannels(); err != nil {
return fmt.Errorf("channels config validation failed: %w", err)
}
if err := c.validateGateway(); err != nil {
return fmt.Errorf("gateway config validation failed: %w", err)
}
if err := c.validateTools(); err != nil {
return fmt.Errorf("tools config validation failed: %w", err)
}
if err := c.ValidateModelList(); err != nil {
return fmt.Errorf("model_list validation failed: %w", err)
}
if err := c.validateHeartbeat(); err != nil {
return fmt.Errorf("heartbeat config validation failed: %w", err)
}
if err := c.validateDevices(); err != nil {
return fmt.Errorf("devices config validation failed: %w", err)
}
return nil
}
// validateAgents validates the agents configuration.
func (c *Config) validateAgents() error {
if err := c.validateAgentDefaults(&c.Agents.Defaults); err != nil {
return fmt.Errorf("defaults: %w", err)
}
for i, agent := range c.Agents.List {
if err := c.validateAgentConfig(agent); err != nil {
return fmt.Errorf("list[%d]: %w", i, err)
}
}
// Check for duplicate agent IDs
seenIDs := make(map[string]bool)
for _, agent := range c.Agents.List {
if seenIDs[agent.ID] {
return fmt.Errorf("duplicate agent id '%s' found", agent.ID)
}
seenIDs[agent.ID] = true
}
return nil
}
// validateAgentDefaults validates the default agent configuration.
func (c *Config) validateAgentDefaults(d *AgentDefaults) error {
if d.Workspace == "" {
return fmt.Errorf("workspace is required")
}
modelName := d.GetModelName()
if modelName != "" {
// Model name validation - should be alphanumeric with some special chars
if !isValidName(modelName) {
return fmt.Errorf("model name '%s' contains invalid characters", modelName)
}
}
if d.MaxTokens < 0 {
return fmt.Errorf("max_tokens must be non-negative, got %d", d.MaxTokens)
}
if d.Temperature != nil && (*d.Temperature < 0 || *d.Temperature > 2.0) {
return fmt.Errorf("temperature must be between 0 and 2.0, got %f", *d.Temperature)
}
if d.MaxToolIterations < 0 {
return fmt.Errorf("max_tool_iterations must be non-negative, got %d", d.MaxToolIterations)
}
if d.SummarizeMessageThreshold < 0 {
return fmt.Errorf("summarize_message_threshold must be non-negative, got %d", d.SummarizeMessageThreshold)
}
if d.SummarizeTokenPercent < 0 || d.SummarizeTokenPercent > 100 {
return fmt.Errorf("summarize_token_percent must be between 0 and 100, got %d", d.SummarizeTokenPercent)
}
if d.MaxMediaSize < 0 {
return fmt.Errorf("max_media_size must be non-negative, got %d", d.MaxMediaSize)
}
return nil
}
// validateAgentConfig validates a single agent configuration.
func (c *Config) validateAgentConfig(agent AgentConfig) error {
if agent.ID == "" {
return fmt.Errorf("agent id is required")
}
if !isValidName(agent.ID) {
return fmt.Errorf("agent id '%s' contains invalid characters", agent.ID)
}
if agent.Name != "" && !isValidName(agent.Name) {
return fmt.Errorf("agent name '%s' contains invalid characters", agent.Name)
}
// Agent-specific model overrides
if agent.Model != nil {
if agent.Model.Primary != "" && !isValidName(agent.Model.Primary) {
return fmt.Errorf("primary model name '%s' contains invalid characters", agent.Model.Primary)
}
for i, fallback := range agent.Model.Fallbacks {
if !isValidName(fallback) {
return fmt.Errorf("fallback model [%d] name '%s' contains invalid characters", i, fallback)
}
}
}
// Subagents config
if agent.Subagents != nil {
model := agent.Subagents.Model
if model != nil {
if model.Primary != "" && !isValidName(model.Primary) {
return fmt.Errorf("subagent primary model name '%s' contains invalid characters", model.Primary)
}
for i, fallback := range model.Fallbacks {
if !isValidName(fallback) {
return fmt.Errorf("subagent fallback model [%d] name '%s' contains invalid characters", i, fallback)
}
}
}
}
return nil
}
// validateChannels validates the channels configuration.
func (c *Config) validateChannels() error {
// Each individual channel config validation occurs in its own function
if err := c.validateWhatsApp(); err != nil {
return fmt.Errorf("whatsapp validation failed: %w", err)
}
if err := c.validateTelegram(); err != nil {
return fmt.Errorf("telegram validation failed: %w", err)
}
if err := c.validateDiscord(); err != nil {
return fmt.Errorf("discord validation failed: %w", err)
}
if err := c.validateFeishu(); err != nil {
return fmt.Errorf("feishu validation failed: %w", err)
}
if err := c.validateMaixCam(); err != nil {
return fmt.Errorf("maixcam validation failed: %w", err)
}
if err := c.validateQQ(); err != nil {
return fmt.Errorf("qq validation failed: %w", err)
}
if err := c.validateDingTalk(); err != nil {
return fmt.Errorf("dingtalk validation failed: %w", err)
}
if err := c.validateSlack(); err != nil {
return fmt.Errorf("slack validation failed: %w", err)
}
if err := c.validateLINE(); err != nil {
return fmt.Errorf("line validation failed: %w", err)
}
if err := c.validateOneBot(); err != nil {
return fmt.Errorf("onebot validation failed: %w", err)
}
if err := c.validateWeCom(); err != nil {
return fmt.Errorf("wecom validation failed: %w", err)
}
if err := c.validateWeComApp(); err != nil {
return fmt.Errorf("wecom_app validation failed: %w", err)
}
if err := c.validateWeComAIBot(); err != nil {
return fmt.Errorf("wecom_aibot validation failed: %w", err)
}
if err := c.validatePico(); err != nil {
return fmt.Errorf("pico validation failed: %w", err)
}
return nil
}
// validateWhatsApp validates the WhatsApp channel configuration.
func (c *Config) validateWhatsApp() error {
cfg := &c.Channels.WhatsApp
if !cfg.Enabled {
return nil
}
if cfg.UseNative && cfg.BridgeURL != "" {
return fmt.Errorf("use_native and bridge_url cannot both be set")
}
return nil
}
// validateTelegram validates the Telegram channel configuration.
func (c *Config) validateTelegram() error {
cfg := &c.Channels.Telegram
if !cfg.Enabled {
return nil
}
if cfg.Token == "" {
return fmt.Errorf("token is required when telegram channel is enabled")
}
return nil
}
// validateDiscord validates the Discord channel configuration.
func (c *Config) validateDiscord() error {
cfg := &c.Channels.Discord
if !cfg.Enabled {
return nil
}
if cfg.Token == "" {
return fmt.Errorf("token is required when discord channel is enabled")
}
return nil
}
// validateFeishu validates the Feishu channel configuration.
func (c *Config) validateFeishu() error {
cfg := &c.Channels.Feishu
if !cfg.Enabled {
return nil
}
if cfg.AppID == "" || cfg.AppSecret == "" {
return fmt.Errorf("app_id and app_secret are required when feishu channel is enabled")
}
return nil
}
// validateQQ validates the QQ channel configuration.
func (c *Config) validateQQ() error {
cfg := &c.Channels.QQ
if !cfg.Enabled {
return nil
}
if cfg.AppID == "" || cfg.AppSecret == "" {
return fmt.Errorf("app_id and app_secret are required when qq channel is enabled")
}
return nil
}
// validateDingTalk validates the DingTalk channel configuration.
func (c *Config) validateDingTalk() error {
cfg := &c.Channels.DingTalk
if !cfg.Enabled {
return nil
}
if cfg.ClientID == "" || cfg.ClientSecret == "" {
return fmt.Errorf("client_id and client_secret are required when dingtalk channel is enabled")
}
return nil
}
// validateMaixCam validates the MaixCam channel configuration.
func (c *Config) validateMaixCam() error {
cfg := &c.Channels.MaixCam
if !cfg.Enabled {
return nil
}
if cfg.Host == "" {
return fmt.Errorf("host is required when maixcam channel is enabled")
}
if cfg.Port <= 0 || cfg.Port > 65535 {
return fmt.Errorf("port must be a valid TCP port (1-65535), got %d", cfg.Port)
}
return nil
}
// validateSlack validates the Slack channel configuration.
func (c *Config) validateSlack() error {
cfg := &c.Channels.Slack
if !cfg.Enabled {
return nil
}
if cfg.BotToken == "" && cfg.AppToken == "" {
return fmt.Errorf("either bot_token or app_token is required when slack channel is enabled")
}
return nil
}
// validateLINE validates the LINE channel configuration.
func (c *Config) validateLINE() error {
cfg := &c.Channels.LINE
if !cfg.Enabled {
return nil
}
if cfg.ChannelSecret == "" {
return fmt.Errorf("channel_secret is required when line channel is enabled")
}
return nil
}
// validateOneBot validates the OneBot channel configuration.
func (c *Config) validateOneBot() error {
cfg := &c.Channels.OneBot
if !cfg.Enabled {
return nil
}
if cfg.WSUrl == "" {
return fmt.Errorf("ws_url is required when onebot channel is enabled")
}
return nil
}
// validateWeCom validates the WeCom (enterprise WeChat) channel configuration.
func (c *Config) validateWeCom() error {
cfg := &c.Channels.WeCom
if !cfg.Enabled {
return nil
}
if cfg.Token == "" || cfg.EncodingAESKey == "" {
return fmt.Errorf("token and encoding_aes_key are required when wecom channel is enabled")
}
if cfg.WebhookPort <= 0 || cfg.WebhookPort > 65535 {
return fmt.Errorf("webhook_port must be a valid TCP port (1-65535), got %d", cfg.WebhookPort)
}
return nil
}
// validateWeComApp validates the WeComApp channel configuration.
func (c *Config) validateWeComApp() error {
cfg := &c.Channels.WeComApp
if !cfg.Enabled {
return nil
}
if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 {
return fmt.Errorf("corp_id, corp_secret, and agent_id are required when wecom_app channel is enabled")
}
if cfg.Token == "" || cfg.EncodingAESKey == "" {
return fmt.Errorf("token and encoding_aes_key are required when wecom_app channel is enabled")
}
if cfg.WebhookPort <= 0 || cfg.WebhookPort > 65535 {
return fmt.Errorf("webhook_port must be a valid TCP port (1-65535), got %d", cfg.WebhookPort)
}
return nil
}
// validateWeComAIBot validates the WeComAIBot channel configuration.
func (c *Config) validateWeComAIBot() error {
cfg := &c.Channels.WeComAIBot
if !cfg.Enabled {
return nil
}
if cfg.Token == "" || cfg.EncodingAESKey == "" {
return fmt.Errorf("token and encoding_aes_key are required when wecom_aibot channel is enabled")
}
if cfg.MaxSteps < 0 {
return fmt.Errorf("max_steps must be non-negative, got %d", cfg.MaxSteps)
}
return nil
}
// validatePico validates the Pico channel configuration.
func (c *Config) validatePico() error {
cfg := &c.Channels.Pico
if !cfg.Enabled {
return nil
}
if cfg.Token == "" {
return fmt.Errorf("token is required when pico channel is enabled")
}
return nil
}
// validateGateway validates the gateway configuration.
func (c *Config) validateGateway() error {
if c.Gateway.Host == "" {
return fmt.Errorf("gateway host is required")
}
if c.Gateway.Port <= 0 || c.Gateway.Port > 65535 {
return fmt.Errorf("gateway port must be a valid TCP port (1-65535), got %d", c.Gateway.Port)
}
return nil
}
// validateTools validates the tools configuration.
func (c *Config) validateTools() error {
// Validation for web tools if they are enabled
webConfig := &c.Tools.Web
if webConfig.Brave.Enabled && webConfig.Brave.APIKey == "" {
return fmt.Errorf("web.brave: api_key is required when enabled is true")
}
if webConfig.Tavily.Enabled && webConfig.Tavily.APIKey == "" {
return fmt.Errorf("web.tavily: api_key is required when enabled is true")
}
// No error if multiple tools are enabled
if webConfig.Tavily.Enabled && webConfig.Brave.Enabled && webConfig.DuckDuckGo.Enabled {
// This is fine
}
// Validating MCP config
if c.Tools.MCP.Enabled {
for name, server := range c.Tools.MCP.Servers {
if name == "" {
return fmt.Errorf("mcp.servers: name cannot be empty")
}
if server.Enabled && (server.Command == "" && server.URL == "") {
return fmt.Errorf("mcp.server '%s': command or URL is required when enabled is true", name)
}
if server.Type != "" && server.Type != "stdio" && server.Type != "sse" && server.Type != "http" {
return fmt.Errorf("mcp.server '%s': type must be 'stdio', 'sse', or 'http', got '%s'", name, server.Type)
}
if server.Type != "stdio" && server.Type != "" && (server.Command != "") {
return fmt.Errorf("mcp.server '%s': command cannot be used with non-stdio type ('%s')", name, server.Type)
}
if (server.Type == "sse" || server.Type == "http") && server.URL == "" {
return fmt.Errorf("mcp.server '%s': URL is required for sse/http types", name)
}
}
}
return nil
}
// validateHeartbeat validates the heartbeat configuration.
func (c *Config) validateHeartbeat() error {
if c.Heartbeat.Enabled && c.Heartbeat.Interval > 0 && c.Heartbeat.Interval < 5 {
return fmt.Errorf("heartbeat interval minimum is 5 minutes, got %d minutes", c.Heartbeat.Interval)
}
return nil
}
// validateDevices validates the devices configuration.
func (c *Config) validateDevices() error {
// Current devices config is valid as-is
return nil
}
// isValidName checks if a name string is valid. It should contain alphanumeric characters
// with hyphens, underscores, and periods but not be empty or have special characters that
// could cause issues in identifiers or file paths.
func isValidName(name string) bool {
if name == "" {
return false
}
// Import regex package for this function
matched, err := regexp.MatchString(`^[a-zA-Z0-9_.-]+$`, name)
if err != nil {
return false
}
if !matched {
return false
}
// Additional check: ensure it doesn't start or end with special separators
if name[0] == '.' || name[0] == '-' || name[0] == '_' ||
name[len(name)-1] == '.' || name[len(name)-1] == '-' || name[len(name)-1] == '_' {
return false
}
// Avoid certain reserved names that shouldn't be used as identifiers
reserved := map[string]bool{"": true, ".": true, "..": true, "nil": true}
if reserved[name] || strings.TrimSpace(name) != name {
return false
}
return true
}