mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(config): make config.Channel to multiple instance support
add new field type to Channel struct config.channels refactor to channel_list update config version to 3 update the docs
This commit is contained in:
+194
-249
@@ -22,7 +22,11 @@ import (
|
||||
var rrCounter atomic.Uint64
|
||||
|
||||
// CurrentVersion is the latest config schema version
|
||||
const CurrentVersion = 2
|
||||
const CurrentVersion = 3
|
||||
|
||||
func init() {
|
||||
initChannel()
|
||||
}
|
||||
|
||||
// Config is the current config structure with version support.
|
||||
type Config struct {
|
||||
@@ -31,7 +35,7 @@ type Config struct {
|
||||
Agents AgentsConfig `json:"agents" yaml:"-"`
|
||||
Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
|
||||
Session SessionConfig `json:"session,omitempty" yaml:"-"`
|
||||
Channels ChannelsConfig `json:"channels" yaml:"channels"`
|
||||
Channels ChannelsConfig `json:"channel_list" yaml:"channel_list"`
|
||||
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway" yaml:"-"`
|
||||
Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
|
||||
@@ -295,27 +299,6 @@ func (d *AgentDefaults) GetModelName() string {
|
||||
return d.ModelName
|
||||
}
|
||||
|
||||
type ChannelsConfig struct {
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"`
|
||||
Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"`
|
||||
Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"`
|
||||
Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"`
|
||||
MaixCam MaixCamConfig `json:"maixcam" yaml:"-"`
|
||||
QQ QQConfig `json:"qq" yaml:"qq,omitempty"`
|
||||
DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"`
|
||||
Slack SlackConfig `json:"slack" yaml:"slack,omitempty"`
|
||||
Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"`
|
||||
LINE LINEConfig `json:"line" yaml:"line,omitempty"`
|
||||
OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"`
|
||||
WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
|
||||
Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"`
|
||||
Pico PicoConfig `json:"pico" yaml:"pico,omitempty"`
|
||||
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
||||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||
VK VKConfig `json:"vk" yaml:"vk,omitempty"`
|
||||
TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"`
|
||||
}
|
||||
|
||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||
type GroupTriggerConfig struct {
|
||||
MentionOnly bool `json:"mention_only,omitempty"`
|
||||
@@ -351,242 +334,161 @@ type StreamingConfig struct {
|
||||
MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"`
|
||||
}
|
||||
|
||||
type WhatsAppConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
||||
BridgeURL string `json:"bridge_url" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||
UseNative bool `json:"use_native" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
|
||||
SessionStorePath string `json:"session_store_path" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
|
||||
type WhatsAppSettings struct {
|
||||
BridgeURL string `json:"bridge_url" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||
UseNative bool `json:"use_native" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
|
||||
SessionStorePath string `json:"session_store_path" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
type TelegramSettings struct {
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
}
|
||||
|
||||
func (c *TelegramConfig) SetToken(token string) {
|
||||
c.Token = *NewSecureString(token)
|
||||
}
|
||||
|
||||
type FeishuConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
|
||||
type FeishuSettings struct {
|
||||
AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
|
||||
AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||
EncryptKey SecureString `json:"encrypt_key,omitzero" yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||
VerificationToken SecureString `json:"verification_token,omitzero" yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
||||
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
|
||||
IsLark bool `json:"is_lark" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||
MentionOnly bool `json:"mention_only" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
||||
type DiscordSettings struct {
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
|
||||
MentionOnly bool `json:"mention_only" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||
}
|
||||
|
||||
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 MaixCamSettings struct {
|
||||
Host string `json:"host" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
||||
Port int `json:"port" yaml:"-" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
||||
}
|
||||
|
||||
type QQConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
|
||||
AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||
AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
MaxMessageLength int `json:"max_message_length" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
|
||||
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
|
||||
SendMarkdown bool `json:"send_markdown" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||
type QQSettings struct {
|
||||
AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||
AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||
MaxMessageLength int `json:"max_message_length" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
|
||||
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
|
||||
SendMarkdown bool `json:"send_markdown" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
|
||||
}
|
||||
|
||||
type DingTalkConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
|
||||
ClientID string `json:"client_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||
ClientSecret SecureString `json:"client_secret,omitzero" yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
||||
type DingTalkSettings struct {
|
||||
ClientID string `json:"client_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||
ClientSecret SecureString `json:"client_secret,omitzero" yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||
}
|
||||
|
||||
type SlackConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
|
||||
BotToken SecureString `json:"bot_token,omitzero" yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||
AppToken SecureString `json:"app_token,omitzero" yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||
type SlackSettings struct {
|
||||
BotToken SecureString `json:"bot_token,omitzero" yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||
AppToken SecureString `json:"app_token,omitzero" yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||
Homeserver string `json:"homeserver" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||
UserID string `json:"user_id" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||
AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||
DeviceID string `json:"device_id,omitempty" yaml:"-"`
|
||||
JoinOnInvite bool `json:"join_on_invite" yaml:"-"`
|
||||
MessageFormat string `json:"message_format,omitempty" yaml:"-"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
|
||||
CryptoDatabasePath string `json:"crypto_database_path,omitempty" yaml:"-"`
|
||||
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
|
||||
type MatrixSettings struct {
|
||||
Homeserver string `json:"homeserver" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||
UserID string `json:"user_id" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||
AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||
DeviceID string `json:"device_id,omitempty" yaml:"-"`
|
||||
JoinOnInvite bool `json:"join_on_invite" yaml:"-"`
|
||||
MessageFormat string `json:"message_format,omitempty" yaml:"-"`
|
||||
CryptoDatabasePath string `json:"crypto_database_path,omitempty" yaml:"-"`
|
||||
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type LINEConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
||||
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||
WebhookHost string `json:"webhook_host" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
|
||||
type LINESettings struct {
|
||||
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||
WebhookHost string `json:"webhook_host" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||
}
|
||||
|
||||
type OneBotConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||
WSUrl string `json:"ws_url" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||
AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||
ReconnectInterval int `json:"reconnect_interval" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
|
||||
type OneBotSettings struct {
|
||||
WSUrl string `json:"ws_url" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||
AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||
ReconnectInterval int `json:"reconnect_interval" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||
}
|
||||
|
||||
type WeComGroupConfig struct {
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"`
|
||||
}
|
||||
|
||||
type WeComConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"`
|
||||
BotID string `json:"bot_id" yaml:"-" env:"BOT_ID"`
|
||||
Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"SECRET"`
|
||||
WebSocketURL string `json:"websocket_url,omitempty" yaml:"-" env:"WEBSOCKET_URL"`
|
||||
SendThinkingMessage bool `json:"send_thinking_message" yaml:"-" env:"SEND_THINKING_MESSAGE"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"ALLOW_FROM"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"REASONING_CHANNEL_ID"`
|
||||
type WeComSettings struct {
|
||||
BotID string `json:"bot_id" yaml:"-" env:"BOT_ID"`
|
||||
Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"SECRET"`
|
||||
WebSocketURL string `json:"websocket_url,omitempty" yaml:"-" env:"WEBSOCKET_URL"`
|
||||
SendThinkingMessage bool `json:"send_thinking_message" yaml:"-" env:"SEND_THINKING_MESSAGE"`
|
||||
}
|
||||
|
||||
func (c *WeComConfig) SetSecret(secret string) {
|
||||
func (c *WeComSettings) SetSecret(secret string) {
|
||||
c.Secret = *NewSecureString(secret)
|
||||
}
|
||||
|
||||
type WeixinConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
|
||||
AccountID string `json:"account_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
|
||||
CDNBaseURL string `json:"cdn_base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
||||
type WeixinSettings struct {
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
|
||||
AccountID string `json:"account_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
|
||||
CDNBaseURL string `json:"cdn_base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
|
||||
}
|
||||
|
||||
// SetToken sets the Weixin token and marks it as dirty for security saving
|
||||
func (c *WeixinConfig) SetToken(token string) {
|
||||
func (c *WeixinSettings) SetToken(token string) {
|
||||
c.Token = *NewSecureString(token)
|
||||
}
|
||||
|
||||
type PicoConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||
AllowTokenQuery bool `json:"allow_token_query,omitempty" yaml:"-"`
|
||||
AllowOrigins []string `json:"allow_origins,omitempty" yaml:"-"`
|
||||
PingInterval int `json:"ping_interval,omitempty" yaml:"-"`
|
||||
ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"`
|
||||
WriteTimeout int `json:"write_timeout,omitempty" yaml:"-"`
|
||||
MaxConnections int `json:"max_connections,omitempty" yaml:"-"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
type PicoSettings struct {
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||
AllowTokenQuery bool `json:"allow_token_query,omitempty" yaml:"-"`
|
||||
AllowOrigins []string `json:"allow_origins,omitempty" yaml:"-"`
|
||||
PingInterval int `json:"ping_interval,omitempty" yaml:"-"`
|
||||
ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"`
|
||||
WriteTimeout int `json:"write_timeout,omitempty" yaml:"-"`
|
||||
MaxConnections int `json:"max_connections,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
// SetToken sets the Pico token and marks it as dirty for security saving
|
||||
func (c *PicoConfig) SetToken(token string) {
|
||||
func (c *PicoSettings) SetToken(token string) {
|
||||
c.Token = *NewSecureString(token)
|
||||
}
|
||||
|
||||
type PicoClientConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"`
|
||||
URL string `json:"url" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"`
|
||||
SessionID string `json:"session_id,omitempty" yaml:"-"`
|
||||
PingInterval int `json:"ping_interval,omitempty" yaml:"-"`
|
||||
ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"`
|
||||
type PicoClientSettings struct {
|
||||
URL string `json:"url" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"`
|
||||
SessionID string `json:"session_id,omitempty" yaml:"-"`
|
||||
PingInterval int `json:"ping_interval,omitempty" yaml:"-"`
|
||||
ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type IRCConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
|
||||
Server string `json:"server" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
|
||||
TLS bool `json:"tls" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_TLS"`
|
||||
Nick string `json:"nick" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_NICK"`
|
||||
User string `json:"user,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_USER"`
|
||||
RealName string `json:"real_name,omitempty" yaml:"-"`
|
||||
Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
|
||||
NickServPassword SecureString `json:"nickserv_password,omitzero" yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
|
||||
SASLUser string `json:"sasl_user" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
|
||||
SASLPassword SecureString `json:"sasl_password,omitzero" yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
|
||||
Channels FlexibleStringSlice `json:"channels" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
|
||||
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" yaml:"-"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
|
||||
type IRCSettings struct {
|
||||
Server string `json:"server" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
|
||||
TLS bool `json:"tls" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_TLS"`
|
||||
Nick string `json:"nick" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_NICK"`
|
||||
User string `json:"user,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_USER"`
|
||||
RealName string `json:"real_name,omitempty" yaml:"-"`
|
||||
Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
|
||||
NickServPassword SecureString `json:"nickserv_password,omitzero" yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
|
||||
SASLUser string `json:"sasl_user" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
|
||||
SASLPassword SecureString `json:"sasl_password,omitzero" yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
|
||||
Channels FlexibleStringSlice `json:"channels" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
|
||||
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type VKConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_VK_ENABLED"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"`
|
||||
GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_VK_ALLOW_FROM"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_REASONING_CHANNEL_ID"`
|
||||
type VKSettings struct {
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"`
|
||||
GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"`
|
||||
}
|
||||
|
||||
func (c *VKConfig) SetToken(token string) {
|
||||
func (c *VKSettings) SetToken(token string) {
|
||||
c.Token = *NewSecureString(token)
|
||||
}
|
||||
|
||||
// TeamsWebhookConfig configures the output-only Microsoft Teams webhook channel.
|
||||
// TeamsWebhookSettings configures the output-only Microsoft Teams webhook channel.
|
||||
// Multiple webhook targets can be configured and selected via ChatID at send time.
|
||||
type TeamsWebhookConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_TEAMS_WEBHOOK_ENABLED"`
|
||||
type TeamsWebhookSettings struct {
|
||||
Webhooks map[string]TeamsWebhookTarget `json:"webhooks" yaml:"webhooks,omitempty"`
|
||||
}
|
||||
|
||||
@@ -990,8 +892,6 @@ func (c *MCPConfig) GetMaxInlineTextChars() int {
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
logger.Debugf("loading config from %s", path)
|
||||
|
||||
updateResolver(filepath.Dir(path))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -1003,7 +903,6 @@ func LoadConfig(path string) (*Config, error) {
|
||||
)
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
logger.Errorf("failed to read config file: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1027,62 +926,114 @@ func LoadConfig(path string) (*Config, error) {
|
||||
"config migrate start",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
// Legacy config (no version field)
|
||||
v, e := loadConfigV0(data)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
|
||||
var m map[string]any
|
||||
m, err = loadConfigMap(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, e = v.Migrate()
|
||||
if e != nil {
|
||||
logger.ErrorF(
|
||||
"config migrate fail",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
return nil, e
|
||||
|
||||
migrateErr := migrateV0ToV1(m)
|
||||
if migrateErr != nil {
|
||||
return nil, fmt.Errorf("V0→V1 migration failed: %w", migrateErr)
|
||||
}
|
||||
logger.InfoF(
|
||||
"config migrate success",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
migrateErr = migrateV1ToV2(m)
|
||||
if migrateErr != nil {
|
||||
return nil, fmt.Errorf("V1→V2 migration failed: %w", migrateErr)
|
||||
}
|
||||
migrateErr = migrateV2ToV3(m)
|
||||
if migrateErr != nil {
|
||||
return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr)
|
||||
}
|
||||
|
||||
var migrated []byte
|
||||
migrated, err = json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err = loadConfig(migrated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = makeBackup(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Load existing security config and merge with migrated one to prevent data loss
|
||||
secErr := loadSecurityConfig(cfg, securityPath(path))
|
||||
if secErr != nil && !os.IsNotExist(secErr) {
|
||||
logger.WarnF(
|
||||
"failed to load existing security config during migration",
|
||||
map[string]any{"error": secErr},
|
||||
)
|
||||
return nil, fmt.Errorf("failed to load existing security config: %w", secErr)
|
||||
}
|
||||
|
||||
defer func(cfg *Config) {
|
||||
_ = SaveConfig(path, cfg)
|
||||
}(cfg)
|
||||
case 1:
|
||||
// V1→V2 migration: infer Enabled and migrate channel config fields
|
||||
// V1→V3 migration: rename channels→channel_list, infer Enabled, migrate channel configs
|
||||
logger.InfoF(
|
||||
"config migrate start",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
cfg, err = loadConfig(data)
|
||||
|
||||
var m map[string]any
|
||||
m, err = loadConfigMap(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secPath := securityPath(path)
|
||||
err = loadSecurityConfig(cfg, secPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("failed to load security config: %w", err)
|
||||
|
||||
migrateErr := migrateV1ToV2(m)
|
||||
if migrateErr != nil {
|
||||
return nil, fmt.Errorf("V1→V2 migration failed: %w", migrateErr)
|
||||
}
|
||||
migrateErr = migrateV2ToV3(m)
|
||||
if migrateErr != nil {
|
||||
return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr)
|
||||
}
|
||||
|
||||
oldCfg := &configV1{Config: *cfg}
|
||||
cfg, err = oldCfg.Migrate()
|
||||
var migrated []byte
|
||||
migrated, err = json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err = loadConfig(migrated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = makeBackup(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func(cfg *Config) {
|
||||
_ = SaveConfig(path, cfg)
|
||||
}(cfg)
|
||||
logger.InfoF(
|
||||
"config migrate success",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
case 2:
|
||||
// V2→V3 migration: rename channels→channel_list, convert flat→nested
|
||||
logger.InfoF(
|
||||
"config migrate start",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
var m map[string]any
|
||||
m, err = loadConfigMap(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
migrateErr := migrateV2ToV3(m)
|
||||
if migrateErr != nil {
|
||||
return nil, fmt.Errorf("V2→V3 migration failed: %w", migrateErr)
|
||||
}
|
||||
|
||||
var migrated []byte
|
||||
migrated, err = json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err = loadConfig(migrated)
|
||||
if err != nil {
|
||||
logger.ErrorF(
|
||||
"config migrate fail",
|
||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1119,6 +1070,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = InitChannelList(cfg.Channels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Expand multi-key configs into separate entries for key-level failover
|
||||
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
|
||||
|
||||
@@ -1199,7 +1154,6 @@ func SaveConfig(path string, cfg *Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof("saving config to %s", path)
|
||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
@@ -1265,15 +1219,6 @@ func (c *Config) SecurityCopyFrom(path string) error {
|
||||
return loadSecurityConfig(c, securityPath(path))
|
||||
}
|
||||
|
||||
// expandMultiKeyModels expands ModelConfig entries with multiple API keys into
|
||||
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
|
||||
// and the original entry's fallbacks are set up to chain through the expanded entries.
|
||||
//
|
||||
// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]}
|
||||
// Becomes:
|
||||
// - {"model_name": "gpt-4", "api_keys": ["k1"], "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
|
||||
// - {"model_name": "gpt-4__key_1", "api_keys": {"k2"}}
|
||||
// - {"model_name": "gpt-4__key_2", "api_keys": {"k3"}}
|
||||
func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
|
||||
var expanded []*ModelConfig
|
||||
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// Channel type constants — single source of truth for all channel type names.
|
||||
const (
|
||||
ChannelPico = "pico"
|
||||
ChannelPicoClient = "pico_client"
|
||||
ChannelTelegram = "telegram"
|
||||
ChannelDiscord = "discord"
|
||||
ChannelFeishu = "feishu"
|
||||
ChannelWeixin = "weixin"
|
||||
ChannelWeCom = "wecom"
|
||||
ChannelDingTalk = "dingtalk"
|
||||
ChannelSlack = "slack"
|
||||
ChannelMatrix = "matrix"
|
||||
ChannelLINE = "line"
|
||||
ChannelOneBot = "onebot"
|
||||
ChannelQQ = "qq"
|
||||
ChannelIRC = "irc"
|
||||
ChannelVK = "vk"
|
||||
ChannelMaixCam = "maixcam"
|
||||
ChannelWhatsApp = "whatsapp"
|
||||
ChannelWhatsAppNative = "whatsapp_native"
|
||||
ChannelTeamsWebHook = "teams_webhook"
|
||||
)
|
||||
|
||||
func initChannel() {
|
||||
registerSingletonChannel(ChannelPico)
|
||||
registerSingletonChannel(ChannelPicoClient)
|
||||
}
|
||||
|
||||
// singletonRegistry stores which channel types are singletons (only allow one instance).
|
||||
// Each channel type should call registerSingletonChannel in its init() if it's a singleton.
|
||||
var singletonRegistry = make(map[string]struct{})
|
||||
|
||||
// registerSingletonChannel marks a channel type as singleton (only one instance allowed).
|
||||
// Should be called from the channel type's init() function.
|
||||
func registerSingletonChannel(channelType string) {
|
||||
singletonRegistry[channelType] = struct{}{}
|
||||
}
|
||||
|
||||
// IsSingletonChannel returns true if the channel type only allows one instance.
|
||||
func IsSingletonChannel(channelType string) bool {
|
||||
_, ok := singletonRegistry[channelType]
|
||||
return ok
|
||||
}
|
||||
|
||||
// RawNode stores raw configuration data as JSON bytes, supporting both JSON and YAML.
|
||||
// Internally uses json.RawMessage, so Decode always uses json.Unmarshal
|
||||
// which correctly respects json struct tags.
|
||||
type RawNode json.RawMessage
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler: stores raw JSON bytes.
|
||||
// NOTE: yaml.Unmarshal may call this when unmarshaling into RawNode fields.
|
||||
// We detect if the input looks like YAML (not JSON) and handle it.
|
||||
func (r *RawNode) UnmarshalJSON(data []byte) error {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if trimmed == "null" || trimmed == "{}" || trimmed == "[]" {
|
||||
*r = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// If it doesn't look like JSON (starts with {, [, ", digit, n, t, f),
|
||||
// it's probably YAML data passed through yaml.Unmarshal.
|
||||
// Try to parse as YAML and convert to JSON.
|
||||
if len(trimmed) > 0 {
|
||||
first := trimmed[0]
|
||||
if first != '{' && first != '[' && first != '"' && first != '-' &&
|
||||
!(first >= '0' && first <= '9') && first != 'n' && first != 't' && first != 'f' {
|
||||
// Looks like YAML, not JSON. Parse as YAML and convert to JSON.
|
||||
var v any
|
||||
if err := yaml.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
jsonData, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*r = jsonData
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
*r = append((*r)[:0:0], data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler: outputs stored JSON bytes.
|
||||
func (r RawNode) MarshalJSON() ([]byte, error) {
|
||||
if len(r) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler: converts YAML node to JSON bytes.
|
||||
// Merges the incoming YAML values with existing data, with YAML taking precedence.
|
||||
func (r *RawNode) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind == 0 {
|
||||
//*r = nil
|
||||
return nil
|
||||
}
|
||||
var v1, v2 map[string]any
|
||||
if len(*r) > 0 {
|
||||
if err := json.Unmarshal(*r, &v1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := value.Decode(&v2); err != nil {
|
||||
return err
|
||||
}
|
||||
v := mergeMap(v1, v2)
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*r = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeMap deeply merges two map[string]any.
|
||||
// dst: base map
|
||||
// src: override map (same keys overwrite dst, nested maps are merged recursively)
|
||||
// Returns a new map without modifying the originals.
|
||||
func mergeMap(dst, src map[string]any) map[string]any {
|
||||
// logger.Infof("mergeMap: dst: %v, src: %v", dst, src)
|
||||
// Create result map to avoid modifying originals
|
||||
result := make(map[string]any)
|
||||
|
||||
// Copy all content from base map
|
||||
for k, v := range dst {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
// Merge override map
|
||||
for k, srcVal := range src {
|
||||
dstVal, exists := result[k]
|
||||
|
||||
if !exists {
|
||||
// Key doesn't exist in base, add directly
|
||||
result[k] = srcVal
|
||||
continue
|
||||
}
|
||||
|
||||
// Both are maps → recursive merge
|
||||
dstMap, dstIsMap := toMap(dstVal)
|
||||
srcMap, srcIsMap := toMap(srcVal)
|
||||
|
||||
if dstIsMap && srcIsMap {
|
||||
result[k] = mergeMap(dstMap, srcMap)
|
||||
} else {
|
||||
// Not both maps → override
|
||||
result[k] = srcVal
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// toMap safely converts any value to map[string]any.
|
||||
func toMap(v any) (map[string]any, bool) {
|
||||
m, ok := v.(map[string]any)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// MarshalYAML implements yaml.ValueMarshaler: converts stored JSON back to a YAML-compatible value.
|
||||
func (r RawNode) MarshalYAML() (any, error) {
|
||||
if len(r) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(r, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Decode unmarshals the stored data into the given target struct using json.Unmarshal.
|
||||
func (r *RawNode) Decode(target any) error {
|
||||
if len(*r) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(*r, target)
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the node has not been populated.
|
||||
func (r *RawNode) IsEmpty() bool {
|
||||
return len(*r) == 0
|
||||
}
|
||||
|
||||
// Channel defines the common fields shared by all channel types.
|
||||
// Channel-specific settings go into Settings (nested format only).
|
||||
// The settings struct should use SecureString/SecureStrings for sensitive fields.
|
||||
//
|
||||
// Decode stores the settings pointer internally; subsequent modifications to the
|
||||
// decoded struct are automatically reflected in MarshalJSON/MarshalYAML.
|
||||
//
|
||||
// MarshalJSON outputs nested format (common fields at top level, settings as sub-key).
|
||||
// MarshalYAML outputs only secure fields (for .security.yml).
|
||||
//
|
||||
// Standard Go JSON/YAML unmarshaling handles nested format correctly:
|
||||
// - JSON: {"enabled": true, "type": "telegram", "settings": {"base_url": "..."}}
|
||||
// - YAML: settings: {token: xxx} (for .security.yml)
|
||||
//
|
||||
//nolint:recvcheck
|
||||
type Channel struct {
|
||||
name string
|
||||
Enabled bool `json:"enabled" yaml:"-"`
|
||||
Type string `json:"type" yaml:"-"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from,omitempty" yaml:"-"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||
Typing TypingConfig `json:"typing,omitempty" yaml:"-"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"`
|
||||
Settings RawNode `json:"settings,omitzero" yaml:"settings,omitempty"`
|
||||
extend any
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for Channel.
|
||||
// Outputs nested format: common fields at top level, channel-specific in "settings".
|
||||
// Secure fields (SecureString/SecureStrings) are removed from settings output.
|
||||
func (b Channel) MarshalJSON() ([]byte, error) {
|
||||
var settings RawNode
|
||||
if b.extend != nil {
|
||||
raw, err := json.Marshal(b.extend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings = raw
|
||||
} else {
|
||||
settings = b.Settings
|
||||
}
|
||||
|
||||
out := b
|
||||
out.Settings = settings
|
||||
|
||||
// Use type alias to bypass our custom MarshalJSON (infinite recursion)
|
||||
type Alias Channel
|
||||
return json.Marshal((*Alias)(&out))
|
||||
}
|
||||
|
||||
// MarshalYAML implements yaml.ValueMarshaler for Channel.
|
||||
// Outputs only secure fields in the Settings YAML (for .security.yml).
|
||||
// If Decode was called, it serializes from the stored extend (reflecting any
|
||||
// modifications); otherwise falls back to decoding Settings via the channel Type
|
||||
// to extract secure fields.
|
||||
func (b Channel) MarshalYAML() (any, error) {
|
||||
decoded, _ := b.GetDecoded()
|
||||
return struct {
|
||||
Settings any `json:"settings,omitzero" yaml:"settings,omitempty"`
|
||||
}{
|
||||
Settings: decoded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the channel name.
|
||||
func (b *Channel) Name() string {
|
||||
return b.name
|
||||
}
|
||||
|
||||
// SetName sets the channel name.
|
||||
func (b *Channel) SetName(name string) {
|
||||
b.name = name
|
||||
}
|
||||
|
||||
// SetSecretField sets a secure field value by field name in the Settings JSON.
|
||||
// NOTE: This only operates on raw Settings. If Decode() has been called,
|
||||
// prefer modifying the typed struct directly — MarshalJSON serializes from extend.
|
||||
func (b *Channel) SetSecretField(fieldName string, value SecureString) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b.Settings, &m); err != nil {
|
||||
return
|
||||
}
|
||||
m[fieldName] = value
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.Settings = data
|
||||
}
|
||||
|
||||
// Decode decodes the Settings node into the given target struct and stores
|
||||
// the pointer internally. Subsequent modifications to the target are
|
||||
// automatically reflected in MarshalJSON/MarshalYAML (no explicit Encode needed).
|
||||
func (b *Channel) Decode(target any) error {
|
||||
if target == nil {
|
||||
return fmt.Errorf("target is nil")
|
||||
}
|
||||
if err := b.Settings.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
b.extend = target
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDecoded returns the previously decoded settings struct.
|
||||
// If Decode hasn't been called yet, it lazily decodes using the channel Type prototype.
|
||||
// Returns an error if decoding fails; the decoded value (possibly nil) is still returned
|
||||
// so callers can distinguish between "not decoded" and "decode failed".
|
||||
func (b *Channel) GetDecoded() (any, error) {
|
||||
if b.extend == nil {
|
||||
// fallback to prototype-based creation
|
||||
if target := newChannelSettings(b.Type); target != nil {
|
||||
if err := b.Decode(target); err != nil {
|
||||
return nil, fmt.Errorf("channel %q failed to decode settings: %w", b.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.extend, nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler for Channel.
|
||||
// Merges the YAML node into the existing Channel.
|
||||
// Supports both nested format (settings: {...}) and flat format (token: xxx).
|
||||
func (b *Channel) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type alias Channel
|
||||
a := alias(*b)
|
||||
err := value.Decode(&a)
|
||||
if err != nil {
|
||||
logger.Errorf("decode yaml error: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
*b = *(*Channel)(&a)
|
||||
|
||||
if len(b.Settings) > 0 {
|
||||
b.extend = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SettingsIsEmpty returns true if Settings has not been populated.
|
||||
func (b *Channel) SettingsIsEmpty() bool {
|
||||
return b.Settings.IsEmpty()
|
||||
}
|
||||
|
||||
// CollectSensitiveValues returns all sensitive string values from this Channel's
|
||||
// decoded settings (extend). Used by the security filter system.
|
||||
func (b Channel) CollectSensitiveValues() []string {
|
||||
if b.extend == nil {
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
collectSensitive(reflect.ValueOf(b.extend), &values)
|
||||
return values
|
||||
}
|
||||
|
||||
// ChannelsConfig maps channel name to its Channel configuration.
|
||||
// Each Channel stores the full channel config in Settings and handles
|
||||
// JSON/YAML serialization (removing/keeping secure fields automatically).
|
||||
//
|
||||
//nolint:recvcheck
|
||||
type ChannelsConfig map[string]*Channel
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler for ChannelsConfig.
|
||||
// This ensures that when loading security.yml, existing Channel instances
|
||||
// are properly merged rather than replaced with new ones.
|
||||
func (c *ChannelsConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||
// yaml.Node Content for a mapping contains alternating key-value nodes
|
||||
// We need to iterate through them in pairs
|
||||
if value.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("expected mapping node, got %v", value.Kind)
|
||||
}
|
||||
|
||||
if *c == nil {
|
||||
*c = make(ChannelsConfig)
|
||||
}
|
||||
|
||||
for i := 0; i < len(value.Content); i += 2 {
|
||||
if i+1 >= len(value.Content) {
|
||||
break
|
||||
}
|
||||
name := value.Content[i].Value
|
||||
node := value.Content[i+1]
|
||||
|
||||
existingBC := (*c)[name]
|
||||
if existingBC != nil {
|
||||
// Channel already exists - call UnmarshalYAML on it
|
||||
// This merges security.yml settings into existing config
|
||||
if err := existingBC.UnmarshalYAML(node); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure name is set (may have been empty before)
|
||||
existingBC.SetName(name)
|
||||
} else {
|
||||
// New channel - create and unmarshal
|
||||
newBC := &Channel{}
|
||||
if err := node.Decode(newBC); err != nil {
|
||||
return err
|
||||
}
|
||||
// Set the channel name from the map key
|
||||
newBC.SetName(name)
|
||||
(*c)[name] = newBC
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for ChannelsConfig.
|
||||
// Sets the channel name from the map key after unmarshaling.
|
||||
func (c *ChannelsConfig) UnmarshalJSON(data []byte) error {
|
||||
// Use a type alias to avoid infinite recursion
|
||||
type channelsConfigAlias map[string]*Channel
|
||||
var raw channelsConfigAlias
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *c == nil {
|
||||
*c = make(ChannelsConfig)
|
||||
}
|
||||
|
||||
for name, bc := range raw {
|
||||
if bc != nil {
|
||||
bc.SetName(name)
|
||||
}
|
||||
(*c)[name] = bc
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the Channel for the given channel name (map key), or nil if not found.
|
||||
func (c ChannelsConfig) Get(name string) *Channel {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c[name]
|
||||
}
|
||||
|
||||
// GetByType returns the Channel for the given channel type, or nil if not found.
|
||||
func (c ChannelsConfig) GetByType(t string) *Channel {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
for _, bc := range c {
|
||||
if bc.Type == t {
|
||||
return bc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetEnabled sets the Enabled field on the Channel with the given name.
|
||||
// Returns false if no channel with that name exists.
|
||||
func (c ChannelsConfig) SetEnabled(name string, enabled bool) bool {
|
||||
bc := c[name]
|
||||
if bc == nil {
|
||||
return false
|
||||
}
|
||||
bc.Enabled = enabled
|
||||
return true
|
||||
}
|
||||
|
||||
// validateSingletonChannels checks that singleton channel types have at most
|
||||
// one enabled instance. Returns an error if a singleton type has multiple enabled channels.
|
||||
func validateSingletonChannels(channels ChannelsConfig) error {
|
||||
typeCount := make(map[string]int)
|
||||
typeNames := make(map[string][]string)
|
||||
for name, bc := range channels {
|
||||
if !bc.Enabled {
|
||||
continue
|
||||
}
|
||||
t := bc.Type
|
||||
if t == "" {
|
||||
t = name
|
||||
}
|
||||
if IsSingletonChannel(t) {
|
||||
typeCount[t]++
|
||||
typeNames[t] = append(typeNames[t], name)
|
||||
}
|
||||
}
|
||||
for t, count := range typeCount {
|
||||
if count > 1 {
|
||||
return fmt.Errorf(
|
||||
"channel type %q is singleton and does not support multiple instances, found %d enabled instances: %v",
|
||||
t,
|
||||
count,
|
||||
typeNames[t],
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BaseFieldNames are JSON keys that belong to Channel, not to channel-specific settings.
|
||||
var BaseFieldNames = map[string]struct{}{
|
||||
"enabled": {},
|
||||
"type": {},
|
||||
"allow_from": {},
|
||||
"reasoning_channel_id": {},
|
||||
"group_trigger": {},
|
||||
"typing": {},
|
||||
"placeholder": {},
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───
|
||||
|
||||
// extractSecureFieldNames uses reflection to find exported fields of type
|
||||
// SecureString or SecureStrings and returns their JSON field names.
|
||||
func extractSecureFieldNames(target any) map[string]struct{} {
|
||||
v := reflect.ValueOf(target)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
t := v.Type()
|
||||
names := make(map[string]struct{})
|
||||
for i := range t.NumField() {
|
||||
f := t.Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
ft := f.Type
|
||||
if ft == reflect.TypeOf(SecureString{}) || ft == reflect.TypeOf(&SecureString{}) ||
|
||||
ft == reflect.TypeOf(SecureStrings{}) || ft == reflect.TypeOf(&SecureStrings{}) {
|
||||
jsonTag := f.Tag.Get("json")
|
||||
name := strings.Split(jsonTag, ",")[0]
|
||||
if name == "" || name == "-" {
|
||||
name = f.Name
|
||||
}
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// mergeRawJSON merges two JSON objects (flat key-value) at the raw byte level.
|
||||
// Overlay values override base values.
|
||||
func mergeRawJSON(base, overlay RawNode) (RawNode, error) {
|
||||
var baseMap, overlayMap map[string]any
|
||||
if len(base) > 0 {
|
||||
if err := json.Unmarshal(base, &baseMap); err != nil {
|
||||
return base, err
|
||||
}
|
||||
}
|
||||
if len(overlay) > 0 {
|
||||
if err := json.Unmarshal(overlay, &overlayMap); err != nil {
|
||||
return base, err
|
||||
}
|
||||
}
|
||||
if baseMap == nil {
|
||||
baseMap = make(map[string]any)
|
||||
}
|
||||
for k, v := range overlayMap {
|
||||
baseMap[k] = v
|
||||
}
|
||||
data, err := json.Marshal(baseMap)
|
||||
if err != nil {
|
||||
return base, err
|
||||
}
|
||||
return RawNode(data), nil
|
||||
}
|
||||
|
||||
// removeSecureFields removes secure fields from the raw JSON.
|
||||
// If secureFields is nil or empty, returns the raw node as-is.
|
||||
func removeSecureFields(r RawNode, secureFields map[string]struct{}) RawNode {
|
||||
if len(r) == 0 || len(secureFields) == 0 {
|
||||
return r
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(r, &m); err != nil {
|
||||
return r
|
||||
}
|
||||
for name := range secureFields {
|
||||
delete(m, name)
|
||||
}
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return r
|
||||
}
|
||||
return RawNode(data)
|
||||
}
|
||||
|
||||
// filterSecureFields keeps only secure fields in the raw JSON.
|
||||
// If secureFields is nil or empty, returns nil (so omitzero/omitempty can omit it).
|
||||
func filterSecureFields(r RawNode, secureFields map[string]struct{}) RawNode {
|
||||
if len(r) == 0 || len(secureFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(r, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
secureMap := make(map[string]any)
|
||||
for name := range secureFields {
|
||||
if val, ok := m[name]; ok {
|
||||
secureMap[name] = val
|
||||
}
|
||||
}
|
||||
if len(secureMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(secureMap)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// channelSettingsFactory maps channel type to a zero-value prototype of the
|
||||
// corresponding Settings struct. InitChannelList uses reflect.New to create
|
||||
// fresh instances, avoiding repeated closure boilerplate.
|
||||
var channelSettingsFactory = map[string]any{
|
||||
ChannelPico: (PicoSettings{}),
|
||||
ChannelPicoClient: (PicoClientSettings{}),
|
||||
ChannelTelegram: (TelegramSettings{}),
|
||||
ChannelDiscord: (DiscordSettings{}),
|
||||
ChannelFeishu: (FeishuSettings{}),
|
||||
ChannelWeixin: (WeixinSettings{}),
|
||||
ChannelWeCom: (WeComSettings{}),
|
||||
ChannelDingTalk: (DingTalkSettings{}),
|
||||
ChannelSlack: (SlackSettings{}),
|
||||
ChannelMatrix: (MatrixSettings{}),
|
||||
ChannelLINE: (LINESettings{}),
|
||||
ChannelOneBot: (OneBotSettings{}),
|
||||
ChannelQQ: (QQSettings{}),
|
||||
ChannelIRC: (IRCSettings{}),
|
||||
ChannelVK: (VKSettings{}),
|
||||
ChannelMaixCam: (MaixCamSettings{}),
|
||||
ChannelWhatsApp: (WhatsAppSettings{}),
|
||||
ChannelWhatsAppNative: (WhatsAppSettings{}),
|
||||
ChannelTeamsWebHook: (TeamsWebhookSettings{}),
|
||||
}
|
||||
|
||||
// newChannelSettings creates a fresh zero-value pointer for the given channel type.
|
||||
// Returns nil if the type is not registered.
|
||||
func newChannelSettings(channelType string) any {
|
||||
proto, ok := channelSettingsFactory[channelType]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return reflect.New(reflect.TypeOf(proto)).Interface()
|
||||
}
|
||||
|
||||
// isValidChannelType returns true if the channel type is a known, registered type.
|
||||
func isValidChannelType(channelType string) bool {
|
||||
_, ok := channelSettingsFactory[channelType]
|
||||
return ok
|
||||
}
|
||||
|
||||
// InitChannelList validates and initializes all channels in the ChannelsConfig.
|
||||
// It performs three steps:
|
||||
// 1. Validates that each channel has a non-empty Type
|
||||
// 2. Validates singleton constraints
|
||||
// 3. Decodes Settings into the correct typed struct based on Type,
|
||||
// so that b.extend contains the actual settings (e.g., PicoSettings)
|
||||
//
|
||||
// After calling this method, callers can safely use b.extend via Decode()
|
||||
// without re-parsing raw Settings.
|
||||
func InitChannelList(channels ChannelsConfig) error {
|
||||
// Step 1 & 3: validate type and decode into typed settings
|
||||
for name, bc := range channels {
|
||||
if bc == nil {
|
||||
delete(channels, name)
|
||||
continue
|
||||
}
|
||||
// Ensure channel name is set from the map key
|
||||
bc.SetName(name)
|
||||
// Infer Type from map key if not explicitly set
|
||||
if bc.Type == "" {
|
||||
bc.Type = name
|
||||
}
|
||||
if !isValidChannelType(bc.Type) {
|
||||
return fmt.Errorf("channel %q has unknown type %q", name, bc.Type)
|
||||
}
|
||||
// Decode into the correct typed settings
|
||||
if target := newChannelSettings(bc.Type); target != nil {
|
||||
if err := bc.Decode(target); err != nil {
|
||||
return fmt.Errorf("channel %q failed to decode settings: %w", name, err)
|
||||
}
|
||||
// Apply env overrides for channel-specific fields via struct tags
|
||||
if err := env.Parse(target); err != nil {
|
||||
// Non-fatal: some env vars may not apply
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: validate singleton constraints
|
||||
if err := validateSingletonChannels(channels); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/credential"
|
||||
)
|
||||
|
||||
// ─── Test extend structs (simplified, settings + secure in one struct) ───
|
||||
|
||||
type testTelegramConfig struct {
|
||||
BaseURL string `json:"base_url" yaml:"-"`
|
||||
Proxy string `json:"proxy" yaml:"-"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty"`
|
||||
}
|
||||
|
||||
type testDiscordConfig struct {
|
||||
MentionOnly bool `json:"mention_only" yaml:"-"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty"`
|
||||
ApiKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// RawNode JSON/YAML round-trip
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestRawNode_JSON_RoundTrip(t *testing.T) {
|
||||
t.Run("unmarshal and decode", func(t *testing.T) {
|
||||
var r RawNode
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"key":"value","num":42}`), &r))
|
||||
assert.False(t, r.IsEmpty())
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, r.Decode(&m))
|
||||
assert.Equal(t, "value", m["key"])
|
||||
assert.Equal(t, float64(42), m["num"])
|
||||
})
|
||||
|
||||
t.Run("marshal round-trip", func(t *testing.T) {
|
||||
r := RawNode(`{"a":1}`)
|
||||
data, err := json.Marshal(r)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"a":1}`, string(data))
|
||||
})
|
||||
|
||||
t.Run("null input", func(t *testing.T) {
|
||||
var r RawNode
|
||||
require.NoError(t, json.Unmarshal([]byte("null"), &r))
|
||||
assert.True(t, r.IsEmpty())
|
||||
|
||||
data, err := json.Marshal(r)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "null", string(data))
|
||||
})
|
||||
|
||||
t.Run("empty node decode", func(t *testing.T) {
|
||||
var r RawNode
|
||||
var m map[string]any
|
||||
require.NoError(t, r.Decode(&m))
|
||||
assert.Nil(t, m)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRawNode_YAML_RoundTrip(t *testing.T) {
|
||||
t.Run("unmarshal and decode", func(t *testing.T) {
|
||||
var r RawNode
|
||||
require.NoError(t, yaml.Unmarshal([]byte("key: value\nnum: 42"), &r))
|
||||
assert.False(t, r.IsEmpty())
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, r.Decode(&m))
|
||||
assert.Equal(t, "value", m["key"])
|
||||
})
|
||||
|
||||
t.Run("marshal round-trip", func(t *testing.T) {
|
||||
r := RawNode(`{"name":"test"}`)
|
||||
data, err := yaml.Marshal(r)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), "name: test")
|
||||
})
|
||||
|
||||
t.Run("empty node marshal", func(t *testing.T) {
|
||||
var r RawNode
|
||||
v, err := yaml.Marshal(r)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "null\n", string(v))
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// JSON unmarshal: extend.json
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_JSON_Unmarshal(t *testing.T) {
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["user1", "user2"],
|
||||
"reasoning_channel_id": "-100xxx",
|
||||
"settings": {
|
||||
"base_url": "https://custom-api.example.com",
|
||||
"use_markdown_v2": true,
|
||||
"streaming": {"enabled": true, "throttle_seconds": 2},
|
||||
"token": "[NOT_HERE]"
|
||||
}
|
||||
}`
|
||||
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
|
||||
assert.True(t, ch.Enabled)
|
||||
assert.Equal(t, "telegram", ch.Type)
|
||||
assert.Equal(t, FlexibleStringSlice{"user1", "user2"}, ch.AllowFrom)
|
||||
assert.Equal(t, "-100xxx", ch.ReasoningChannelID)
|
||||
assert.False(t, ch.SettingsIsEmpty())
|
||||
|
||||
// Decode into combined struct
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL)
|
||||
assert.True(t, cfg.UseMarkdownV2)
|
||||
assert.True(t, cfg.Streaming.Enabled)
|
||||
assert.Equal(t, 2, cfg.Streaming.ThrottleSeconds)
|
||||
// SecureString.UnmarshalJSON("[NOT_HERE]") → no-op → empty
|
||||
assert.Equal(t, "", cfg.Token.String())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// JSON marshal: secure fields masked as [NOT_HERE]
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_JSON_Marshal_SecureMasked(t *testing.T) {
|
||||
ch := Channel{
|
||||
Enabled: true,
|
||||
Type: ChannelTelegram,
|
||||
name: "my_telegram",
|
||||
Settings: mustParseRawNode(
|
||||
`{"base_url": "https://api.telegram.org", "proxy": "socks5://127.0.0.1:1080", "token": "123456:SECRET"}`,
|
||||
),
|
||||
}
|
||||
// Decode to register secure field names
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
|
||||
data, err := json.MarshalIndent(ch, "", " ")
|
||||
require.NoError(t, err)
|
||||
t.Logf("JSON output:\n%s", string(data))
|
||||
|
||||
assert.NotContains(t, string(data), "token")
|
||||
assert.NotContains(t, string(data), "123456:SECRET")
|
||||
assert.NotContains(t, string(data), "SECRET")
|
||||
assert.Contains(t, string(data), "base_url")
|
||||
assert.Contains(t, string(data), "proxy")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// YAML unmarshal: security.yml — only secure data
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_YAML_Unmarshal(t *testing.T) {
|
||||
yamlData := `
|
||||
settings:
|
||||
token: "789012:XYZ-TOKEN"
|
||||
`
|
||||
|
||||
var ch Channel
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch))
|
||||
assert.False(t, ch.SettingsIsEmpty())
|
||||
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "789012:XYZ-TOKEN", cfg.Token.String())
|
||||
assert.Equal(t, "", cfg.BaseURL)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// YAML marshal: only secure fields
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_YAML_Marshal_OnlySecureFields(t *testing.T) {
|
||||
ch := Channel{
|
||||
Enabled: true,
|
||||
Type: ChannelTelegram,
|
||||
name: "my_telegram",
|
||||
Settings: mustParseRawNode(`{"base_url": "https://api.telegram.org", "token": "123456:SECRET"}`),
|
||||
}
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
|
||||
data, err := yaml.Marshal(ch)
|
||||
require.NoError(t, err)
|
||||
t.Logf("YAML output:\n%s", string(data))
|
||||
|
||||
assert.NotContains(t, string(data), "NOT_HERE")
|
||||
assert.Contains(t, string(data), "token")
|
||||
assert.Contains(t, string(data), "123456:SECRET")
|
||||
// Non-secure fields must NOT appear in YAML output
|
||||
assert.NotContains(t, string(data), "base_url")
|
||||
assert.NotContains(t, string(data), "proxy")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// extractSecureFieldNames
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestExtractSecureFieldNames(t *testing.T) {
|
||||
t.Run("telegram extend", func(t *testing.T) {
|
||||
names := extractSecureFieldNames(&testTelegramConfig{})
|
||||
assert.Equal(t, map[string]struct{}{"token": {}}, names)
|
||||
})
|
||||
|
||||
t.Run("discord extend", func(t *testing.T) {
|
||||
names := extractSecureFieldNames(&testDiscordConfig{})
|
||||
assert.Equal(t, map[string]struct{}{"token": {}, "api_keys": {}}, names)
|
||||
})
|
||||
|
||||
t.Run("non-struct target", func(t *testing.T) {
|
||||
names := extractSecureFieldNames("not a struct")
|
||||
assert.Nil(t, names)
|
||||
})
|
||||
|
||||
t.Run("struct without secure fields", func(t *testing.T) {
|
||||
type NoSecure struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
names := extractSecureFieldNames(&NoSecure{})
|
||||
assert.Empty(t, names)
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// mergeRawJSON
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestMergeRawJSON(t *testing.T) {
|
||||
t.Run("overlay overrides base", func(t *testing.T) {
|
||||
base := RawNode(`{"base_url": "old", "token": "[NOT_HERE]"}`)
|
||||
overlay := RawNode(`{"token": "REAL_TOKEN"}`)
|
||||
merged, err := mergeRawJSON(base, overlay)
|
||||
require.NoError(t, err)
|
||||
|
||||
var m map[string]any
|
||||
json.Unmarshal(merged, &m)
|
||||
assert.Equal(t, "old", m["base_url"])
|
||||
assert.Equal(t, "REAL_TOKEN", m["token"])
|
||||
})
|
||||
|
||||
t.Run("empty overlay", func(t *testing.T) {
|
||||
base := RawNode(`{"base_url": "https://api.telegram.org"}`)
|
||||
merged, err := mergeRawJSON(base, nil)
|
||||
require.NoError(t, err)
|
||||
// mergeRawJSON normalizes JSON through unmarshal→marshal, so compare parsed values
|
||||
var orig, result map[string]any
|
||||
json.Unmarshal(base, &orig)
|
||||
json.Unmarshal(merged, &result)
|
||||
assert.Equal(t, orig, result)
|
||||
})
|
||||
|
||||
t.Run("empty base", func(t *testing.T) {
|
||||
overlay := RawNode(`{"token": "NEW"}`)
|
||||
merged, err := mergeRawJSON(nil, overlay)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(merged), `"token":"NEW"`)
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Full flow: extend.json + security.yml merge
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_FullFlow_JSON_YAML_Merge(t *testing.T) {
|
||||
// Step 1: Load from extend.json
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["admin"],
|
||||
"settings": {
|
||||
"base_url": "https://custom-api.example.com",
|
||||
"use_markdown_v2": true,
|
||||
"streaming": {"enabled": true},
|
||||
"token": "[NOT_HERE]"
|
||||
}
|
||||
}`
|
||||
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
assert.True(t, ch.Enabled)
|
||||
|
||||
// Step 2: Load secure from security.yml
|
||||
yamlData := `
|
||||
settings:
|
||||
token: "123456:REAL-TOKEN"
|
||||
`
|
||||
//var yamlOverlay struct {
|
||||
// Settings RawNode `yaml:"settings"`
|
||||
//}
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch))
|
||||
|
||||
// Step 3: Merge
|
||||
// require.NoError(t, ch.MergeSecure(yamlOverlay.Settings))
|
||||
|
||||
// Step 4: Decode merged result
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL)
|
||||
assert.True(t, cfg.UseMarkdownV2)
|
||||
assert.Equal(t, "123456:REAL-TOKEN", cfg.Token.String())
|
||||
|
||||
// Step 5: Save extend.json → token masked as [NOT_HERE]
|
||||
outJSON, err := json.MarshalIndent(ch, "", " ")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved extend.json:\n%s", string(outJSON))
|
||||
assert.NotContains(t, string(outJSON), "token")
|
||||
assert.NotContains(t, string(outJSON), "REAL-TOKEN")
|
||||
assert.Contains(t, string(outJSON), "base_url")
|
||||
|
||||
// Step 6: Save security.yml → only token
|
||||
outYAML, err := yaml.Marshal(ch)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved security.yml:\n%s", string(outYAML))
|
||||
assert.Contains(t, string(outYAML), "123456:REAL-TOKEN")
|
||||
assert.NotContains(t, string(outYAML), "NOT_HERE")
|
||||
assert.NotContains(t, string(outYAML), "base_url")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Multiple channels in a list
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_MultipleChannels(t *testing.T) {
|
||||
type ChannelsWrapper struct {
|
||||
Channels ChannelsConfig `json:"channels" yaml:"channels"`
|
||||
}
|
||||
|
||||
jsonData := `{
|
||||
"channels": {
|
||||
"tg1": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"settings": {"base_url": "https://api.telegram.org", "token": "[NOT_HERE]"}
|
||||
},
|
||||
"tg2": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"settings": {"base_url": "https://custom-api.example.com", "proxy": "socks5://proxy:1080", "token": "[NOT_HERE]"}
|
||||
},
|
||||
"discord1": {
|
||||
"enabled": true,
|
||||
"type": "discord",
|
||||
"settings": {"mention_only": true, "token": "[NOT_HERE]"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
var wrapper ChannelsWrapper
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &wrapper))
|
||||
require.Len(t, wrapper.Channels, 3)
|
||||
|
||||
// Decode each channel to register secure field names
|
||||
for name, ch := range wrapper.Channels {
|
||||
ch.SetName(name) // Set channel name
|
||||
switch ch.Type {
|
||||
case "telegram":
|
||||
var tc testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&tc))
|
||||
case "discord":
|
||||
var dc testDiscordConfig
|
||||
require.NoError(t, ch.Decode(&dc))
|
||||
default:
|
||||
t.Logf("Unknown channel type: %s for channel %s", ch.Type, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Load secrets from YAML
|
||||
yamlData := `
|
||||
channels:
|
||||
tg1:
|
||||
settings:
|
||||
token: "TOKEN_1"
|
||||
tg2:
|
||||
settings:
|
||||
token: "TOKEN_2"
|
||||
discord1:
|
||||
settings:
|
||||
token: "DISCORD_TOKEN"
|
||||
`
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper))
|
||||
|
||||
// Verify first telegram
|
||||
var tg1 testTelegramConfig
|
||||
require.NoError(t, wrapper.Channels["tg1"].Decode(&tg1))
|
||||
assert.Equal(t, "https://api.telegram.org", tg1.BaseURL)
|
||||
assert.Equal(t, "TOKEN_1", tg1.Token.String())
|
||||
|
||||
// Verify second telegram
|
||||
var tg2 testTelegramConfig
|
||||
require.NoError(t, wrapper.Channels["tg2"].Decode(&tg2))
|
||||
assert.Equal(t, "https://custom-api.example.com", tg2.BaseURL)
|
||||
assert.Equal(t, "socks5://proxy:1080", tg2.Proxy)
|
||||
assert.Equal(t, "TOKEN_2", tg2.Token.String())
|
||||
|
||||
// Verify discord
|
||||
var disc testDiscordConfig
|
||||
require.NoError(t, wrapper.Channels["discord1"].Decode(&disc))
|
||||
assert.True(t, disc.MentionOnly)
|
||||
assert.Equal(t, "DISCORD_TOKEN", disc.Token.String())
|
||||
|
||||
// Save JSON → all tokens removed
|
||||
outJSON, err := json.MarshalIndent(wrapper, "", " ")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved extend.json:\n%s", string(outJSON))
|
||||
assert.NotContains(t, string(outJSON), "token")
|
||||
assert.NotContains(t, string(outJSON), "TOKEN_1")
|
||||
assert.NotContains(t, string(outJSON), "DISCORD_TOKEN")
|
||||
|
||||
// Save YAML → only tokens
|
||||
outYAML, err := yaml.Marshal(wrapper)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved security.yml:\n%s", string(outYAML))
|
||||
assert.Contains(t, string(outYAML), "TOKEN_1")
|
||||
assert.Contains(t, string(outYAML), "DISCORD_TOKEN")
|
||||
assert.NotContains(t, string(outYAML), "base_url")
|
||||
assert.NotContains(t, string(outYAML), "NOT_HERE")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Empty/missing settings
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_EmptySettings(t *testing.T) {
|
||||
// Flat format with only common fields: enabled and type are extracted to Channel,
|
||||
// Settings should be empty (no channel-specific fields)
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "telegram"
|
||||
}`
|
||||
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
// All fields are common fields — Settings should be empty
|
||||
assert.True(t, ch.SettingsIsEmpty())
|
||||
|
||||
// Decode into typed config — common fields like enabled/type are extracted,
|
||||
// channel-specific fields should be empty
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "", cfg.BaseURL)
|
||||
assert.Equal(t, "", cfg.Token.String())
|
||||
}
|
||||
|
||||
func TestChannel_NestedEmptySettings(t *testing.T) {
|
||||
// Nested format with empty settings
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"settings": {}
|
||||
}`
|
||||
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
assert.True(t, ch.SettingsIsEmpty())
|
||||
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "", cfg.BaseURL)
|
||||
assert.Equal(t, "", cfg.Token.String())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// YAML merge with fewer channels than JSON
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_MultipleChannels_PartialYAMLMerge(t *testing.T) {
|
||||
type ChannelsWrapper struct {
|
||||
Channels ChannelsConfig `json:"channels" yaml:"channels"`
|
||||
}
|
||||
|
||||
// JSON has 3 channels
|
||||
jsonData := `{
|
||||
"channels": {
|
||||
"tg1": {"enabled": true, "type": "telegram", "settings": {"base_url": "https://api.telegram.org", "token": "[NOT_HERE]"}},
|
||||
"tg2": {"enabled": true, "type": "telegram", "settings": {"base_url": "https://custom-api.example.com", "token": "[NOT_HERE]"}},
|
||||
"discord1": {"enabled": true, "type": "discord", "settings": {"mention_only": true, "token": "[NOT_HERE]"}}
|
||||
}
|
||||
}`
|
||||
var wrapper ChannelsWrapper
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &wrapper))
|
||||
require.Len(t, wrapper.Channels, 3)
|
||||
t.Logf("wrapper: %v", wrapper)
|
||||
|
||||
// YAML has only 2 secrets (missing tg2)
|
||||
yamlData := `
|
||||
channels:
|
||||
tg1:
|
||||
settings:
|
||||
token: "TOKEN_1"
|
||||
discord1:
|
||||
settings:
|
||||
token: "DISCORD_TOKEN"
|
||||
`
|
||||
//var yamlWrapper struct {
|
||||
// Channels map[string]struct {
|
||||
// Settings RawNode `yaml:"settings"`
|
||||
// } `yaml:"channels"`
|
||||
//}
|
||||
assert.True(t, wrapper.Channels["tg1"].Enabled)
|
||||
assert.Equal(t, "telegram", wrapper.Channels["tg1"].Type)
|
||||
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper))
|
||||
t.Logf("yamlWrapper: %v", wrapper)
|
||||
require.Len(t, wrapper.Channels, 3)
|
||||
|
||||
assert.True(t, wrapper.Channels["tg1"].Enabled)
|
||||
|
||||
t.Logf("wrapper: %v", string(wrapper.Channels["tg1"].Settings))
|
||||
//// Merge by name; missing keys are simply absent from the YAML map (no-op)
|
||||
//for name, ch := range wrapper.Channels {
|
||||
// if overlay, ok := yamlWrapper.Channels[name]; ok {
|
||||
// require.NoError(t, ch.MergeSecure(overlay.Settings))
|
||||
// }
|
||||
//}
|
||||
|
||||
// tg1: merged from YAML
|
||||
var tg1 TelegramSettings
|
||||
require.NoError(t, wrapper.Channels["tg1"].Decode(&tg1))
|
||||
assert.Equal(t, "TOKEN_1", tg1.Token.String())
|
||||
|
||||
// tg2: no YAML entry → MergeSecure not called → token stays [NOT_HERE] → empty
|
||||
var tg2 TelegramSettings
|
||||
require.NoError(t, wrapper.Channels["tg2"].Decode(&tg2))
|
||||
assert.Equal(t, "", tg2.Token.String())
|
||||
assert.Equal(t, "https://custom-api.example.com", tg2.BaseURL)
|
||||
|
||||
// discord1: merged from YAML
|
||||
var disc DiscordSettings
|
||||
require.NoError(t, wrapper.Channels["discord1"].Decode(&disc))
|
||||
assert.Equal(t, "DISCORD_TOKEN", disc.Token.String())
|
||||
assert.True(t, disc.MentionOnly)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// YAML list: channels with secure data
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_YAML_ListWithSecure(t *testing.T) {
|
||||
yamlData := `
|
||||
channels:
|
||||
tg_bot:
|
||||
enabled: true
|
||||
type: telegram
|
||||
settings:
|
||||
token: "TG_TOKEN_FROM_YAML"
|
||||
discord_bot:
|
||||
enabled: true
|
||||
type: discord
|
||||
settings:
|
||||
token: "DISCORD_TOKEN_FROM_YAML"
|
||||
`
|
||||
|
||||
type ChannelsWrapper struct {
|
||||
Channels map[string]*Channel `yaml:"channels"`
|
||||
}
|
||||
|
||||
var wrapper ChannelsWrapper
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &wrapper))
|
||||
require.Len(t, wrapper.Channels, 2)
|
||||
|
||||
var tg testTelegramConfig
|
||||
require.NoError(t, wrapper.Channels["tg_bot"].Decode(&tg))
|
||||
assert.Equal(t, "TG_TOKEN_FROM_YAML", tg.Token.String())
|
||||
|
||||
var disc testDiscordConfig
|
||||
require.NoError(t, wrapper.Channels["discord_bot"].Decode(&disc))
|
||||
assert.Equal(t, "DISCORD_TOKEN_FROM_YAML", disc.Token.String())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// removeSecureFields / filterSecureFields unit tests
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestRemoveSecureFields(t *testing.T) {
|
||||
t.Run("removes known secure fields", func(t *testing.T) {
|
||||
r := RawNode(`{"base_url": "https://api.telegram.org", "token": "SECRET"}`)
|
||||
names := map[string]struct{}{"token": {}}
|
||||
cleaned := removeSecureFields(r, names)
|
||||
|
||||
var m map[string]any
|
||||
json.Unmarshal(cleaned, &m)
|
||||
assert.Equal(t, "https://api.telegram.org", m["base_url"])
|
||||
assert.NotContains(t, m, "token")
|
||||
})
|
||||
|
||||
t.Run("nil secureFields returns as-is", func(t *testing.T) {
|
||||
r := RawNode(`{"token": "SECRET"}`)
|
||||
cleaned := removeSecureFields(r, nil)
|
||||
assert.Equal(t, string(r), string(cleaned))
|
||||
})
|
||||
|
||||
t.Run("empty raw returns as-is", func(t *testing.T) {
|
||||
cleaned := removeSecureFields(nil, map[string]struct{}{"token": {}})
|
||||
assert.Nil(t, cleaned)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterSecureFields(t *testing.T) {
|
||||
t.Run("keeps only secure fields", func(t *testing.T) {
|
||||
r := RawNode(`{"base_url": "https://api.telegram.org", "token": "SECRET"}`)
|
||||
names := map[string]struct{}{"token": {}}
|
||||
filtered := filterSecureFields(r, names)
|
||||
|
||||
var m map[string]any
|
||||
json.Unmarshal(filtered, &m)
|
||||
assert.NotContains(t, m, "base_url")
|
||||
assert.Equal(t, "SECRET", m["token"])
|
||||
})
|
||||
|
||||
t.Run("nil secureFields returns nil", func(t *testing.T) {
|
||||
r := RawNode(`{"token": "SECRET"}`)
|
||||
filtered := filterSecureFields(r, nil)
|
||||
assert.Nil(t, filtered)
|
||||
})
|
||||
|
||||
t.Run("empty raw returns nil", func(t *testing.T) {
|
||||
filtered := filterSecureFields(nil, map[string]struct{}{"token": {}})
|
||||
assert.Nil(t, filtered)
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// SecureStrings (ApiKeys) full flow
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_SecureStrings_ApiKeys(t *testing.T) {
|
||||
// Step 1: Load from extend.json
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "discord",
|
||||
"settings": {
|
||||
"mention_only": true,
|
||||
"token": "[NOT_HERE]",
|
||||
"api_keys": ["[NOT_HERE]"]
|
||||
}
|
||||
}`
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
|
||||
// Step 2: Merge secure from security.yml
|
||||
yamlData := `
|
||||
settings:
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
api_keys:
|
||||
- "KEY_1"
|
||||
- "KEY_2"
|
||||
`
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch))
|
||||
|
||||
// Step 3: Decode — both SecureString and SecureStrings should be populated
|
||||
var cfg testDiscordConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.True(t, cfg.MentionOnly)
|
||||
assert.Equal(t, "DISCORD_BOT_TOKEN", cfg.Token.String())
|
||||
require.Len(t, cfg.ApiKeys, 2)
|
||||
assert.Equal(t, "KEY_1", cfg.ApiKeys[0].String())
|
||||
assert.Equal(t, "KEY_2", cfg.ApiKeys[1].String())
|
||||
|
||||
// Step 4: Save extend.json — both secure fields removed
|
||||
outJSON, err := json.MarshalIndent(ch, "", " ")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved extend.json:\n%s", string(outJSON))
|
||||
assert.NotContains(t, string(outJSON), "token")
|
||||
assert.NotContains(t, string(outJSON), "api_keys")
|
||||
assert.NotContains(t, string(outJSON), "DISCORD_BOT_TOKEN")
|
||||
assert.NotContains(t, string(outJSON), "KEY")
|
||||
assert.Contains(t, string(outJSON), "mention_only")
|
||||
|
||||
// Step 5: Save security.yml — only secure fields
|
||||
outYAML, err := yaml.Marshal(ch)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved security.yml:\n%s", string(outYAML))
|
||||
assert.Contains(t, string(outYAML), "DISCORD_BOT_TOKEN")
|
||||
assert.Contains(t, string(outYAML), "KEY_1")
|
||||
assert.Contains(t, string(outYAML), "KEY_2")
|
||||
assert.NotContains(t, string(outYAML), "mention_only")
|
||||
assert.NotContains(t, string(outYAML), "NOT_HERE")
|
||||
}
|
||||
|
||||
func TestChannel_SecureStrings_ApiKeys_EmptyInJSON(t *testing.T) {
|
||||
// JSON has no api_keys field
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "discord",
|
||||
"settings": {
|
||||
"mention_only": true,
|
||||
"token": "[NOT_HERE]"
|
||||
}
|
||||
}`
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
|
||||
// Merge with api_keys from YAML
|
||||
yamlData := `
|
||||
settings:
|
||||
token: "MY_TOKEN"
|
||||
api_keys:
|
||||
- "KEY_A"
|
||||
`
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch))
|
||||
|
||||
var cfg testDiscordConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "MY_TOKEN", cfg.Token.String())
|
||||
require.Len(t, cfg.ApiKeys, 1)
|
||||
assert.Equal(t, "KEY_A", cfg.ApiKeys[0].String())
|
||||
}
|
||||
|
||||
func TestChannel_SecureStrings_ApiKeys_NoMerge(t *testing.T) {
|
||||
// JSON only, no merge — SecureStrings should be empty
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "discord",
|
||||
"settings": {
|
||||
"mention_only": true,
|
||||
"token": "[NOT_HERE]",
|
||||
"api_keys": ["[NOT_HERE]"]
|
||||
}
|
||||
}`
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
|
||||
var cfg testDiscordConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.True(t, cfg.MentionOnly)
|
||||
assert.Equal(t, "", cfg.Token.String())
|
||||
// ["[NOT_HERE]"] entries are filtered out → nil
|
||||
assert.Nil(t, cfg.ApiKeys)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// enc:// token: encrypt → store → merge → decrypt
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_EncryptedToken(t *testing.T) {
|
||||
mustSetupSSHKey(t)
|
||||
|
||||
const testPassphrase = "test-passphrase-123"
|
||||
const plainToken = "123456:MY-SECRET-TOKEN"
|
||||
|
||||
// Encrypt the token to get an enc:// string
|
||||
encrypted, err := credential.Encrypt(testPassphrase, "", plainToken)
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(encrypted, "enc://"), "expected enc:// prefix, got: %s", encrypted)
|
||||
t.Logf("encrypted token: %s", encrypted)
|
||||
|
||||
// Replace PassphraseProvider so SecureString.fromRaw can decrypt
|
||||
orig := credential.PassphraseProvider
|
||||
credential.PassphraseProvider = func() string { return testPassphrase }
|
||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||
|
||||
// Step 1: Load from extend.json (token is [NOT_HERE])
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"settings": {
|
||||
"base_url": "https://api.telegram.org",
|
||||
"use_markdown_v2": true,
|
||||
"token": "[NOT_HERE]"
|
||||
}
|
||||
}`
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
|
||||
// ── Scenario: security.yml stores enc:// token ──
|
||||
yamlData := `
|
||||
settings:
|
||||
token: ` + encrypted + `
|
||||
`
|
||||
// Step 2: Merge enc:// token from security.yml
|
||||
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &ch))
|
||||
|
||||
// Step 3: Decode — SecureString.fromRaw resolves enc:// → plaintext
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "https://api.telegram.org", cfg.BaseURL)
|
||||
assert.True(t, cfg.UseMarkdownV2)
|
||||
// The key assertion: enc:// is decrypted to the original plaintext
|
||||
assert.Equal(t, plainToken, cfg.Token.String(),
|
||||
"SecureString should resolve enc:// to the original plaintext token")
|
||||
|
||||
// Step 4: Save extend.json → token masked as [NOT_HERE]
|
||||
outJSON, err := json.MarshalIndent(ch, "", " ")
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(outJSON), "token")
|
||||
assert.NotContains(t, string(outJSON), plainToken)
|
||||
assert.NotContains(t, string(outJSON), "enc://")
|
||||
|
||||
// Step 5: Save security.yml → token preserved as enc://
|
||||
outYAML, err := yaml.Marshal(ch)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved security.yml:\n%s", string(outYAML))
|
||||
assert.Contains(t, string(outYAML), encrypted)
|
||||
assert.NotContains(t, string(outYAML), plainToken)
|
||||
assert.NotContains(t, string(outYAML), "NOT_HERE")
|
||||
assert.NotContains(t, string(outYAML), "base_url")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// enc:// token directly in extend.json (edge case)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_EncryptedTokenInJSON(t *testing.T) {
|
||||
mustSetupSSHKey(t)
|
||||
|
||||
const testPassphrase = "json-enc-passphrase"
|
||||
const plainToken = "BOT-TOKEN-FROM-JSON"
|
||||
const plainToken2 = "new token2"
|
||||
|
||||
encrypted, err := credential.Encrypt(testPassphrase, "", plainToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
orig := credential.PassphraseProvider
|
||||
credential.PassphraseProvider = func() string { return testPassphrase }
|
||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||
|
||||
// extend.json with enc:// token directly (no merge needed)
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"settings": {
|
||||
"base_url": "https://api.telegram.org",
|
||||
"token": ` + `"` + encrypted + `"` + `
|
||||
}
|
||||
}`
|
||||
t.Logf("JSON data:\n%s", jsonData)
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
|
||||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, plainToken, cfg.Token.String(),
|
||||
"enc:// token in JSON should be decrypted correctly")
|
||||
|
||||
cfg.Token.Set(plainToken2)
|
||||
// No explicit Encode needed — Decode stored &cfg, so modifications are
|
||||
// automatically reflected in MarshalJSON/MarshalYAML.
|
||||
|
||||
// Save JSON → masked as [NOT_HERE]
|
||||
outJSON, err := json.MarshalIndent(ch, "", " ")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved extend.json:\n%s", string(outJSON))
|
||||
assert.NotContains(t, string(outJSON), "token")
|
||||
assert.NotContains(t, string(outJSON), plainToken2)
|
||||
assert.NotContains(t, string(outJSON), "enc://")
|
||||
|
||||
// Save YAML → only token, re-encrypted
|
||||
outYAML, err := yaml.Marshal(ch)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Saved security.yml:\n%s", string(outYAML))
|
||||
// MarshalYAML re-encrypts with a new random salt/nonce, so verify via round-trip
|
||||
assert.Contains(t, string(outYAML), "enc://")
|
||||
|
||||
// Round-trip: unmarshal YAML output through Channel and verify decryption
|
||||
var ch2 Channel
|
||||
require.NoError(t, yaml.Unmarshal(outYAML, &ch2))
|
||||
var cfg2 testTelegramConfig
|
||||
require.NoError(t, ch2.Decode(&cfg2))
|
||||
assert.Equal(t, plainToken2, cfg2.Token.String())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// enc:// token with missing passphrase → error
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
func TestChannel_EncryptedToken_NoPassphrase(t *testing.T) {
|
||||
mustSetupSSHKey(t)
|
||||
|
||||
const testPassphrase = "will-be-removed"
|
||||
encrypted, err := credential.Encrypt(testPassphrase, "", "secret-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure no passphrase is available
|
||||
orig := credential.PassphraseProvider
|
||||
credential.PassphraseProvider = func() string { return "" }
|
||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||
|
||||
jsonData := `{
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"settings": {
|
||||
"base_url": "https://api.telegram.org",
|
||||
"token": ` + `"` + encrypted + `"` + `
|
||||
}
|
||||
}`
|
||||
var ch Channel
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonData), &ch))
|
||||
|
||||
var cfg testTelegramConfig
|
||||
// Decode should fail because enc:// cannot be decrypted without passphrase
|
||||
err = ch.Decode(&cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "passphrase required")
|
||||
}
|
||||
|
||||
// ─── helper ───
|
||||
|
||||
func mustParseRawNode(s string) RawNode {
|
||||
return RawNode(s)
|
||||
}
|
||||
+600
-978
File diff suppressed because it is too large
Load Diff
@@ -100,8 +100,18 @@ const (
|
||||
)
|
||||
|
||||
// SecureStrings is a slice of SecureString
|
||||
//
|
||||
//nolint:recvcheck
|
||||
type SecureStrings []*SecureString
|
||||
|
||||
// IsZero returns true if the SecureStrings is nil or empty.
|
||||
func (s SecureStrings) IsZero() bool {
|
||||
if !callerFromYaml() {
|
||||
return true
|
||||
}
|
||||
return len(s) == 0
|
||||
}
|
||||
|
||||
// Values returns the decrypted/resolved values
|
||||
func (s *SecureStrings) Values() []string {
|
||||
if s == nil {
|
||||
@@ -149,7 +159,22 @@ func (s *SecureStrings) UnmarshalJSON(value []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*s = v
|
||||
// Filter out elements where SecureString.UnmarshalJSON was a no-op
|
||||
// (e.g. "[NOT_HERE]" entries), keeping only actually populated values.
|
||||
filtered := make(SecureStrings, 0, len(v))
|
||||
for _, ss := range v {
|
||||
if ss == nil {
|
||||
continue
|
||||
}
|
||||
if ss.resolved != "" || ss.raw != "" {
|
||||
filtered = append(filtered, ss)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
*s = nil
|
||||
} else {
|
||||
*s = filtered
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -167,16 +192,16 @@ func callerFromYaml() bool {
|
||||
d := filepath.Dir(file)
|
||||
// check the caller is from yaml.v
|
||||
if !strings.Contains(d, "yaml.v") {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
// IsZero returns true if the SecureString is empty
|
||||
// if caller not yaml, just return true for prevent marshal this field
|
||||
func (s SecureString) IsZero() bool {
|
||||
if callerFromYaml() {
|
||||
if !callerFromYaml() {
|
||||
return true
|
||||
}
|
||||
return s.resolved == ""
|
||||
|
||||
+101
-50
@@ -80,23 +80,6 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersConfig_IsEmpty(t *testing.T) {
|
||||
var empty providersConfigV0
|
||||
t.Logf("empty: %+v", empty)
|
||||
if !empty.IsEmpty() {
|
||||
t.Fatal("empty providersConfig should report empty")
|
||||
}
|
||||
|
||||
novita := providersConfigV0{
|
||||
Novita: providerConfigV0{
|
||||
APIKey: "test-key",
|
||||
},
|
||||
}
|
||||
if novita.IsEmpty() {
|
||||
t.Fatal("providersConfig with novita settings should not report empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfig_FullParse(t *testing.T) {
|
||||
jsonData := `{
|
||||
"agents": {
|
||||
@@ -322,17 +305,56 @@ func TestDefaultConfig_Gateway(t *testing.T) {
|
||||
func TestDefaultConfig_Channels(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Channels.Telegram.Enabled {
|
||||
t.Error("Telegram should be disabled by default")
|
||||
for name, bc := range cfg.Channels {
|
||||
if bc.Enabled {
|
||||
t.Errorf("Channel %q should be disabled by default", name)
|
||||
}
|
||||
}
|
||||
if cfg.Channels.Discord.Enabled {
|
||||
t.Error("Discord should be disabled by default")
|
||||
}
|
||||
|
||||
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
|
||||
channels := ChannelsConfig{
|
||||
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
||||
"pico2": &Channel{Enabled: true, Type: ChannelPico},
|
||||
}
|
||||
if cfg.Channels.Slack.Enabled {
|
||||
t.Error("Slack should be disabled by default")
|
||||
err := validateSingletonChannels(channels)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multiple pico channels, got nil")
|
||||
}
|
||||
if cfg.Channels.Matrix.Enabled {
|
||||
t.Error("Matrix should be disabled by default")
|
||||
if !strings.Contains(err.Error(), "singleton") {
|
||||
t.Fatalf("expected singleton error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSingletonChannels_AllowsSingleInstance(t *testing.T) {
|
||||
channels := ChannelsConfig{
|
||||
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
||||
}
|
||||
err := validateSingletonChannels(channels)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for single pico channel, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSingletonChannels_IgnoresDisabledInstances(t *testing.T) {
|
||||
channels := ChannelsConfig{
|
||||
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
||||
"pico2": &Channel{Enabled: false, Type: ChannelPico},
|
||||
}
|
||||
err := validateSingletonChannels(channels)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error when only one pico channel is enabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSingletonChannels_AllowsMultiInstanceTypes(t *testing.T) {
|
||||
channels := ChannelsConfig{
|
||||
"tg1": &Channel{Enabled: true, Type: ChannelTelegram},
|
||||
"tg2": &Channel{Enabled: true, Type: ChannelTelegram},
|
||||
}
|
||||
err := validateSingletonChannels(channels)
|
||||
if err != nil {
|
||||
t.Fatalf("telegram should allow multiple instances, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +429,9 @@ func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Channels.Telegram.Placeholder.Enabled = false
|
||||
if bc := cfg.Channels.Get("telegram"); bc != nil {
|
||||
bc.Placeholder.Enabled = false
|
||||
}
|
||||
|
||||
if err := SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig failed: %v", err)
|
||||
@@ -428,7 +452,8 @@ func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
if loaded.Channels.Telegram.Placeholder.Enabled {
|
||||
bc := loaded.Channels.Get("telegram")
|
||||
if bc != nil && bc.Placeholder.Enabled {
|
||||
t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip")
|
||||
}
|
||||
}
|
||||
@@ -1079,7 +1104,8 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if got := []string(cfg.Channels.Telegram.Placeholder.Text); len(got) != 1 || got[0] != "Thinking..." {
|
||||
bc := cfg.Channels.Get("telegram")
|
||||
if got := []string(bc.Placeholder.Text); len(got) != 1 || got[0] != "Thinking..." {
|
||||
t.Fatalf("placeholder.text = %#v, want [\"Thinking...\"]", got)
|
||||
}
|
||||
}
|
||||
@@ -1701,28 +1727,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
// Channel tokens
|
||||
Channels: ChannelsConfig{
|
||||
Telegram: TelegramConfig{Token: *NewSecureString("telegram-bot-token-abcdef")},
|
||||
Discord: DiscordConfig{Token: *NewSecureString("discord-bot-token-xyz789")},
|
||||
Slack: SlackConfig{
|
||||
BotToken: *NewSecureString("xoxb-slack-bot-token"),
|
||||
AppToken: *NewSecureString("xapp-slack-app-token"),
|
||||
},
|
||||
Matrix: MatrixConfig{AccessToken: *NewSecureString("matrix-access-token-abc")},
|
||||
Feishu: FeishuConfig{
|
||||
AppSecret: *NewSecureString("feishu-app-secret-123"),
|
||||
EncryptKey: *NewSecureString("feishu-encrypt-key"),
|
||||
},
|
||||
DingTalk: DingTalkConfig{ClientSecret: *NewSecureString("dingtalk-client-secret")},
|
||||
OneBot: OneBotConfig{AccessToken: *NewSecureString("onebot-access-token")},
|
||||
WeCom: WeComConfig{Secret: *NewSecureString("wecom-secret")},
|
||||
Pico: PicoConfig{Token: *NewSecureString("pico-token-abc123")},
|
||||
IRC: IRCConfig{
|
||||
Password: *NewSecureString("irc-password"),
|
||||
NickServPassword: *NewSecureString("nickserv-pass"),
|
||||
SASLPassword: *NewSecureString("sasl-pass"),
|
||||
},
|
||||
},
|
||||
Channels: testChannelsConfigWithTokens(),
|
||||
Tools: ToolsConfig{
|
||||
FilterSensitiveData: true,
|
||||
FilterMinLength: 8,
|
||||
@@ -1974,3 +1979,49 @@ func TestMakeBackup_SameDateSuffix(t *testing.T) {
|
||||
t.Errorf("config backup date = %q, security backup date = %q, should match", configDate, secDate)
|
||||
}
|
||||
}
|
||||
|
||||
func testChannelsConfigWithTokens() ChannelsConfig {
|
||||
channels := make(ChannelsConfig)
|
||||
type chDef struct {
|
||||
name string
|
||||
cfg any
|
||||
}
|
||||
defs := []chDef{
|
||||
{"telegram", TelegramSettings{Token: *NewSecureString("telegram-bot-token-abcdef")}},
|
||||
{"discord", DiscordSettings{Token: *NewSecureString("discord-bot-token-xyz789")}},
|
||||
{
|
||||
"slack",
|
||||
SlackSettings{
|
||||
BotToken: *NewSecureString("xoxb-slack-bot-token"),
|
||||
AppToken: *NewSecureString("xapp-slack-app-token"),
|
||||
},
|
||||
},
|
||||
{"matrix", MatrixSettings{AccessToken: *NewSecureString("matrix-access-token-abc")}},
|
||||
{
|
||||
"feishu",
|
||||
FeishuSettings{
|
||||
AppSecret: *NewSecureString("feishu-app-secret-123"),
|
||||
EncryptKey: *NewSecureString("feishu-encrypt-key"),
|
||||
},
|
||||
},
|
||||
{"dingtalk", DingTalkSettings{ClientSecret: *NewSecureString("dingtalk-client-secret")}},
|
||||
{"onebot", OneBotSettings{AccessToken: *NewSecureString("onebot-access-token")}},
|
||||
{"wecom", WeComSettings{Secret: *NewSecureString("wecom-secret")}},
|
||||
{"pico", PicoSettings{Token: *NewSecureString("pico-token-abc123")}},
|
||||
{
|
||||
"irc",
|
||||
IRCSettings{
|
||||
Password: *NewSecureString("irc-password"),
|
||||
NickServPassword: *NewSecureString("nickserv-pass"),
|
||||
SASLPassword: *NewSecureString("sasl-pass"),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, def := range defs {
|
||||
// Create Channel directly with settings to preserve SecureString values
|
||||
bc := &Channel{Type: def.name}
|
||||
bc.Decode(def.cfg)
|
||||
channels[def.name] = bc
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
+90
-105
@@ -6,6 +6,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg"
|
||||
@@ -44,111 +45,7 @@ func DefaultConfig() *Config {
|
||||
Session: SessionConfig{
|
||||
DMScope: "per-channel-peer",
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
WhatsApp: WhatsAppConfig{
|
||||
Enabled: false,
|
||||
BridgeURL: "ws://localhost:3001",
|
||||
UseNative: false,
|
||||
SessionStorePath: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: false,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
Typing: TypingConfig{Enabled: true},
|
||||
Placeholder: PlaceholderConfig{
|
||||
Enabled: true,
|
||||
Text: FlexibleStringSlice{"Thinking... 💭"},
|
||||
},
|
||||
Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200},
|
||||
UseMarkdownV2: false,
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: false,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
MentionOnly: false,
|
||||
},
|
||||
MaixCam: MaixCamConfig{
|
||||
Enabled: false,
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
QQ: QQConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
MaxMessageLength: 2000,
|
||||
MaxBase64FileSizeMiB: 0,
|
||||
},
|
||||
DingTalk: DingTalkConfig{
|
||||
Enabled: false,
|
||||
ClientID: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Slack: SlackConfig{
|
||||
Enabled: false,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Matrix: MatrixConfig{
|
||||
Enabled: false,
|
||||
Homeserver: "https://matrix.org",
|
||||
UserID: "",
|
||||
DeviceID: "",
|
||||
JoinOnInvite: true,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
GroupTrigger: GroupTriggerConfig{
|
||||
MentionOnly: true,
|
||||
},
|
||||
Placeholder: PlaceholderConfig{
|
||||
Enabled: true,
|
||||
Text: FlexibleStringSlice{"Thinking... 💭"},
|
||||
},
|
||||
CryptoDatabasePath: "",
|
||||
CryptoPassphrase: "",
|
||||
},
|
||||
LINE: LINEConfig{
|
||||
Enabled: false,
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookPort: 18791,
|
||||
WebhookPath: "/webhook/line",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
||||
},
|
||||
OneBot: OneBotConfig{
|
||||
Enabled: false,
|
||||
WSUrl: "ws://127.0.0.1:3001",
|
||||
ReconnectInterval: 5,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
WeCom: WeComConfig{
|
||||
Enabled: false,
|
||||
BotID: "",
|
||||
WebSocketURL: "wss://openws.work.weixin.qq.com",
|
||||
SendThinkingMessage: true,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Weixin: WeixinConfig{
|
||||
Enabled: false,
|
||||
BaseURL: "https://ilinkai.weixin.qq.com/",
|
||||
CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
Proxy: "",
|
||||
},
|
||||
Pico: PicoConfig{
|
||||
Enabled: false,
|
||||
PingInterval: 30,
|
||||
ReadTimeout: 60,
|
||||
WriteTimeout: 10,
|
||||
MaxConnections: 100,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
},
|
||||
Channels: defaultChannels(),
|
||||
Hooks: HooksConfig{
|
||||
Enabled: true,
|
||||
Defaults: HookDefaultsConfig{
|
||||
@@ -535,3 +432,91 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultChannels() ChannelsConfig {
|
||||
defs := map[string]any{
|
||||
"whatsapp": map[string]any{
|
||||
"settings": map[string]any{
|
||||
"bridge_url": "ws://localhost:3001",
|
||||
},
|
||||
},
|
||||
"telegram": map[string]any{
|
||||
"typing": map[string]any{"enabled": true},
|
||||
"placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}},
|
||||
"settings": map[string]any{
|
||||
"streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200},
|
||||
"use_markdown_v2": false,
|
||||
},
|
||||
},
|
||||
"feishu": map[string]any{},
|
||||
"discord": map[string]any{},
|
||||
"maixcam": map[string]any{
|
||||
"settings": map[string]any{"host": "0.0.0.0", "port": 18790},
|
||||
},
|
||||
"qq": map[string]any{
|
||||
"settings": map[string]any{"max_message_length": 2000},
|
||||
},
|
||||
"dingtalk": map[string]any{},
|
||||
"slack": map[string]any{},
|
||||
"matrix": map[string]any{
|
||||
"group_trigger": map[string]any{"mention_only": true},
|
||||
"placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}},
|
||||
"settings": map[string]any{
|
||||
"homeserver": "https://matrix.org",
|
||||
"join_on_invite": true,
|
||||
},
|
||||
},
|
||||
"line": map[string]any{
|
||||
"group_trigger": map[string]any{"mention_only": true},
|
||||
"settings": map[string]any{
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
},
|
||||
},
|
||||
"onebot": map[string]any{
|
||||
"settings": map[string]any{
|
||||
"ws_url": "ws://127.0.0.1:3001",
|
||||
"reconnect_interval": 5,
|
||||
},
|
||||
},
|
||||
"wecom": map[string]any{
|
||||
"settings": map[string]any{
|
||||
"websocket_url": "wss://openws.work.weixin.qq.com",
|
||||
"send_thinking_message": true,
|
||||
},
|
||||
},
|
||||
"weixin": map[string]any{
|
||||
"settings": map[string]any{
|
||||
"base_url": "https://ilinkai.weixin.qq.com/",
|
||||
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
|
||||
},
|
||||
},
|
||||
"pico": map[string]any{
|
||||
"settings": map[string]any{
|
||||
"ping_interval": 30,
|
||||
"read_timeout": 60,
|
||||
"write_timeout": 10,
|
||||
"max_connections": 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
channels := make(ChannelsConfig, len(defs))
|
||||
for name, def := range defs {
|
||||
data, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
bc := &Channel{}
|
||||
if err := json.Unmarshal(data, bc); err != nil {
|
||||
continue
|
||||
}
|
||||
bc.SetName(name)
|
||||
if bc.Type == "" {
|
||||
bc.Type = name
|
||||
}
|
||||
channels[name] = bc
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
+370
-490
@@ -7,13 +7,14 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type migratable interface {
|
||||
Migrate() (*Config, error)
|
||||
}
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// buildModelWithProtocol constructs a model string with protocol prefix.
|
||||
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
|
||||
@@ -26,491 +27,6 @@ func buildModelWithProtocol(protocol, model string) string {
|
||||
return protocol + "/" + model
|
||||
}
|
||||
|
||||
// v0ConvertProvidersToModelList converts the old providersConfigV0 to a slice of ModelConfig.
|
||||
// This enables backward compatibility with existing configurations.
|
||||
// It preserves the user's configured model from agents.defaults.model when possible.
|
||||
func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// providerMigrationConfig defines how to migrate a provider from old config to new format.
|
||||
type providerMigrationConfig struct {
|
||||
// providerNames are the possible names used in agents.defaults.provider
|
||||
providerNames []string
|
||||
// protocol is the protocol prefix for the model field
|
||||
protocol string
|
||||
// buildConfig creates the ModelConfig from ProviderConfig
|
||||
buildConfig func(p providersConfigV0) (modelConfigV0, bool)
|
||||
}
|
||||
|
||||
// Get user's configured provider and model
|
||||
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||
userModel := cfg.Agents.Defaults.GetModelName()
|
||||
|
||||
p := cfg.Providers
|
||||
|
||||
var result []modelConfigV0
|
||||
|
||||
// Track if we've applied the legacy model name fix (only for first provider)
|
||||
legacyModelNameApplied := false
|
||||
|
||||
// Define migration rules for each provider
|
||||
migrations := []providerMigrationConfig{
|
||||
{
|
||||
providerNames: []string{"openai", "gpt"},
|
||||
protocol: "openai",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "openai",
|
||||
Model: "openai/gpt-5.4",
|
||||
APIKey: p.OpenAI.APIKey,
|
||||
APIBase: p.OpenAI.APIBase,
|
||||
Proxy: p.OpenAI.Proxy,
|
||||
RequestTimeout: p.OpenAI.RequestTimeout,
|
||||
AuthMethod: p.OpenAI.AuthMethod,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"anthropic", "claude"},
|
||||
protocol: "anthropic",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "anthropic",
|
||||
Model: "anthropic/claude-sonnet-4.6",
|
||||
APIKey: p.Anthropic.APIKey,
|
||||
APIBase: p.Anthropic.APIBase,
|
||||
Proxy: p.Anthropic.Proxy,
|
||||
RequestTimeout: p.Anthropic.RequestTimeout,
|
||||
AuthMethod: p.Anthropic.AuthMethod,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"litellm"},
|
||||
protocol: "litellm",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "litellm",
|
||||
Model: "litellm/auto",
|
||||
APIKey: p.LiteLLM.APIKey,
|
||||
APIBase: p.LiteLLM.APIBase,
|
||||
Proxy: p.LiteLLM.Proxy,
|
||||
RequestTimeout: p.LiteLLM.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"openrouter"},
|
||||
protocol: "openrouter",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "openrouter",
|
||||
Model: "openrouter/auto",
|
||||
APIKey: p.OpenRouter.APIKey,
|
||||
APIBase: p.OpenRouter.APIBase,
|
||||
Proxy: p.OpenRouter.Proxy,
|
||||
RequestTimeout: p.OpenRouter.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"groq"},
|
||||
protocol: "groq",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "groq",
|
||||
Model: "groq/llama-3.1-70b-versatile",
|
||||
APIKey: p.Groq.APIKey,
|
||||
APIBase: p.Groq.APIBase,
|
||||
Proxy: p.Groq.Proxy,
|
||||
RequestTimeout: p.Groq.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"zhipu", "glm"},
|
||||
protocol: "zhipu",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "zhipu",
|
||||
Model: "zhipu/glm-4",
|
||||
APIKey: p.Zhipu.APIKey,
|
||||
APIBase: p.Zhipu.APIBase,
|
||||
Proxy: p.Zhipu.Proxy,
|
||||
RequestTimeout: p.Zhipu.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"vllm"},
|
||||
protocol: "vllm",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "vllm",
|
||||
Model: "vllm/auto",
|
||||
APIKey: p.VLLM.APIKey,
|
||||
APIBase: p.VLLM.APIBase,
|
||||
Proxy: p.VLLM.Proxy,
|
||||
RequestTimeout: p.VLLM.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"gemini", "google"},
|
||||
protocol: "gemini",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "gemini",
|
||||
Model: "gemini/gemini-pro",
|
||||
APIKey: p.Gemini.APIKey,
|
||||
APIBase: p.Gemini.APIBase,
|
||||
Proxy: p.Gemini.Proxy,
|
||||
RequestTimeout: p.Gemini.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"nvidia"},
|
||||
protocol: "nvidia",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "nvidia",
|
||||
Model: "nvidia/meta/llama-3.1-8b-instruct",
|
||||
APIKey: p.Nvidia.APIKey,
|
||||
APIBase: p.Nvidia.APIBase,
|
||||
Proxy: p.Nvidia.Proxy,
|
||||
RequestTimeout: p.Nvidia.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"ollama"},
|
||||
protocol: "ollama",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "ollama",
|
||||
Model: "ollama/llama3",
|
||||
APIKey: p.Ollama.APIKey,
|
||||
APIBase: p.Ollama.APIBase,
|
||||
Proxy: p.Ollama.Proxy,
|
||||
RequestTimeout: p.Ollama.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"moonshot", "kimi"},
|
||||
protocol: "moonshot",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "moonshot",
|
||||
Model: "moonshot/kimi",
|
||||
APIKey: p.Moonshot.APIKey,
|
||||
APIBase: p.Moonshot.APIBase,
|
||||
Proxy: p.Moonshot.Proxy,
|
||||
RequestTimeout: p.Moonshot.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"shengsuanyun"},
|
||||
protocol: "shengsuanyun",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "shengsuanyun",
|
||||
Model: "shengsuanyun/auto",
|
||||
APIKey: p.ShengSuanYun.APIKey,
|
||||
APIBase: p.ShengSuanYun.APIBase,
|
||||
Proxy: p.ShengSuanYun.Proxy,
|
||||
RequestTimeout: p.ShengSuanYun.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"deepseek"},
|
||||
protocol: "deepseek",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "deepseek",
|
||||
Model: "deepseek/deepseek-chat",
|
||||
APIKey: p.DeepSeek.APIKey,
|
||||
APIBase: p.DeepSeek.APIBase,
|
||||
Proxy: p.DeepSeek.Proxy,
|
||||
RequestTimeout: p.DeepSeek.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"cerebras"},
|
||||
protocol: "cerebras",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "cerebras",
|
||||
Model: "cerebras/llama-3.3-70b",
|
||||
APIKey: p.Cerebras.APIKey,
|
||||
APIBase: p.Cerebras.APIBase,
|
||||
Proxy: p.Cerebras.Proxy,
|
||||
RequestTimeout: p.Cerebras.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"vivgrid"},
|
||||
protocol: "vivgrid",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "vivgrid",
|
||||
Model: "vivgrid/auto",
|
||||
APIKey: p.Vivgrid.APIKey,
|
||||
APIBase: p.Vivgrid.APIBase,
|
||||
Proxy: p.Vivgrid.Proxy,
|
||||
RequestTimeout: p.Vivgrid.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"volcengine", "doubao"},
|
||||
protocol: "volcengine",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "volcengine",
|
||||
Model: "volcengine/doubao-pro",
|
||||
APIKey: p.VolcEngine.APIKey,
|
||||
APIBase: p.VolcEngine.APIBase,
|
||||
Proxy: p.VolcEngine.Proxy,
|
||||
RequestTimeout: p.VolcEngine.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"github_copilot", "copilot"},
|
||||
protocol: "github-copilot",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "github-copilot",
|
||||
Model: "github-copilot/gpt-5.4",
|
||||
APIBase: p.GitHubCopilot.APIBase,
|
||||
ConnectMode: p.GitHubCopilot.ConnectMode,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"antigravity"},
|
||||
protocol: "antigravity",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "antigravity",
|
||||
Model: "antigravity/gemini-2.0-flash",
|
||||
APIKey: p.Antigravity.APIKey,
|
||||
AuthMethod: p.Antigravity.AuthMethod,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"qwen", "tongyi"},
|
||||
protocol: "qwen",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "qwen",
|
||||
Model: "qwen/qwen-max",
|
||||
APIKey: p.Qwen.APIKey,
|
||||
APIBase: p.Qwen.APIBase,
|
||||
Proxy: p.Qwen.Proxy,
|
||||
RequestTimeout: p.Qwen.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"mistral"},
|
||||
protocol: "mistral",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "mistral",
|
||||
Model: "mistral/mistral-small-latest",
|
||||
APIKey: p.Mistral.APIKey,
|
||||
APIBase: p.Mistral.APIBase,
|
||||
Proxy: p.Mistral.Proxy,
|
||||
RequestTimeout: p.Mistral.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"avian"},
|
||||
protocol: "avian",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "avian",
|
||||
Model: "avian/deepseek/deepseek-v3.2",
|
||||
APIKey: p.Avian.APIKey,
|
||||
APIBase: p.Avian.APIBase,
|
||||
Proxy: p.Avian.Proxy,
|
||||
RequestTimeout: p.Avian.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"longcat"},
|
||||
protocol: "longcat",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "longcat",
|
||||
Model: "longcat/LongCat-Flash-Thinking",
|
||||
APIKey: p.LongCat.APIKey,
|
||||
APIBase: p.LongCat.APIBase,
|
||||
Proxy: p.LongCat.Proxy,
|
||||
RequestTimeout: p.LongCat.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
providerNames: []string{"modelscope"},
|
||||
protocol: "modelscope",
|
||||
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
|
||||
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
|
||||
return modelConfigV0{}, false
|
||||
}
|
||||
return modelConfigV0{
|
||||
ModelName: "modelscope",
|
||||
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
APIKey: p.ModelScope.APIKey,
|
||||
APIBase: p.ModelScope.APIBase,
|
||||
Proxy: p.ModelScope.Proxy,
|
||||
RequestTimeout: p.ModelScope.RequestTimeout,
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Process each provider migration
|
||||
for _, m := range migrations {
|
||||
mc, ok := m.buildConfig(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the user's configured provider
|
||||
if slices.Contains(m.providerNames, userProvider) && userModel != "" {
|
||||
// Use the user's configured model instead of default
|
||||
mc.Model = buildModelWithProtocol(m.protocol, userModel)
|
||||
} else if userProvider == "" && userModel != "" && !legacyModelNameApplied {
|
||||
// Legacy config: no explicit provider field but model is specified
|
||||
// Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it
|
||||
// This maintains backward compatibility with old configs that relied on implicit provider selection
|
||||
mc.ModelName = userModel
|
||||
mc.Model = buildModelWithProtocol(m.protocol, userModel)
|
||||
legacyModelNameApplied = true
|
||||
}
|
||||
|
||||
result = append(result, mc)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// loadConfigV0 loads a legacy config (no version field)
|
||||
func loadConfigV0(data []byte) (migratable, error) {
|
||||
var v0 configV0
|
||||
if err := json.Unmarshal(data, &v0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v0.migrateChannelConfigs()
|
||||
|
||||
// Auto-migrate: if only legacy providers config exists, convert to model_list
|
||||
if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() {
|
||||
newModelList := v0ConvertProvidersToModelList(&v0)
|
||||
// Convert []ModelConfig to []modelConfigV0
|
||||
v0.ModelList = make([]modelConfigV0, len(newModelList))
|
||||
for i, m := range newModelList {
|
||||
v0.ModelList[i] = modelConfigV0{
|
||||
ModelName: m.ModelName,
|
||||
Model: m.Model,
|
||||
APIBase: m.APIBase,
|
||||
Proxy: m.Proxy,
|
||||
Fallbacks: m.Fallbacks,
|
||||
AuthMethod: m.AuthMethod,
|
||||
ConnectMode: m.ConnectMode,
|
||||
Workspace: m.Workspace,
|
||||
RPM: m.RPM,
|
||||
MaxTokensField: m.MaxTokensField,
|
||||
RequestTimeout: m.RequestTimeout,
|
||||
ThinkingLevel: m.ThinkingLevel,
|
||||
APIKey: m.APIKey,
|
||||
APIKeys: m.APIKeys,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &v0, nil
|
||||
}
|
||||
|
||||
// loadConfigV1 loads a version 1 config (current schema)
|
||||
func loadConfig(data []byte) (*Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
@@ -557,3 +73,367 @@ func mergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
func compareInt(v any, expected int) bool {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val == expected
|
||||
case float64:
|
||||
return val == float64(expected)
|
||||
case nil:
|
||||
return expected == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// migrateV0ToV1 converts a V0 (legacy, no version field) config JSON to V1 format:
|
||||
// 1. Migrates legacy providers to model_list
|
||||
// 2. Migrates agents.defaults.model → agents.defaults.model_name
|
||||
// 3. Sets version to 1
|
||||
func migrateV0ToV1(m map[string]any) error {
|
||||
if !compareInt(m["version"], 0) {
|
||||
return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"])
|
||||
}
|
||||
|
||||
// Migrate agents.defaults.model → agents.defaults.model_name
|
||||
if agents, ok := m["agents"].(map[string]any); ok {
|
||||
if defaults, ok := agents["defaults"].(map[string]any); ok {
|
||||
if model, hasModel := defaults["model"]; hasModel {
|
||||
if _, hasModelName := defaults["model_name"]; !hasModelName {
|
||||
defaults["model_name"] = model
|
||||
}
|
||||
delete(defaults, "model")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy providers to model_list if no model_list exists
|
||||
if _, hasModelList := m["model_list"]; !hasModelList {
|
||||
if providers, hasProviders := m["providers"]; hasProviders {
|
||||
if provMap, ok := providers.(map[string]any); ok && !isProvidersMapEmpty(provMap) {
|
||||
// Extract user's provider and model from agents.defaults
|
||||
userProvider := ""
|
||||
userModel := ""
|
||||
if agents, ok := m["agents"].(map[string]any); ok {
|
||||
if defaults, ok := agents["defaults"].(map[string]any); ok {
|
||||
if v, ok := defaults["provider"].(string); ok {
|
||||
userProvider = v
|
||||
}
|
||||
// Check both model_name (new) and model (old) fields
|
||||
if v, ok := defaults["model_name"].(string); ok && v != "" {
|
||||
userModel = v
|
||||
} else if v, ok := defaults["model"].(string); ok && v != "" {
|
||||
userModel = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modelListRaw := v0ProvidersMapToModelList(provMap, userProvider, userModel)
|
||||
if len(modelListRaw) > 0 {
|
||||
m["model_list"] = modelListRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert model_list api_key → api_keys
|
||||
if modelList, ok := m["model_list"].([]any); ok {
|
||||
for _, model := range modelList {
|
||||
if mVal, ok := model.(map[string]any); ok {
|
||||
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
|
||||
mVal["api_keys"] = ss
|
||||
delete(mVal, "api_key")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m["version"] = 1
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func toUniqueStrings(s any, ss any) []string {
|
||||
set := make(map[string]struct{})
|
||||
|
||||
// process s
|
||||
if str, ok := s.(string); ok && str != "" {
|
||||
set[str] = struct{}{}
|
||||
}
|
||||
|
||||
// process ss as []any (JSON arrays)
|
||||
if slice, ok := ss.([]any); ok {
|
||||
for _, item := range slice {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
set[str] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process ss as []string
|
||||
if slice, ok := ss.([]string); ok {
|
||||
for _, item := range slice {
|
||||
if item != "" {
|
||||
set[item] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// map to slice
|
||||
result := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
result = append(result, k)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// migrateV1ToV2 converts a V1 config JSON to V2 format:
|
||||
// 1. Migrates legacy "mention_only" to "group_trigger.mention_only"
|
||||
// 2. Infers "enabled" field for models
|
||||
// 3. Sets version to 2
|
||||
func migrateV1ToV2(m map[string]any) error {
|
||||
if !compareInt(m["version"], 1) {
|
||||
return fmt.Errorf("migrateV1ToV2: expected version 1, got %#v", m["version"])
|
||||
}
|
||||
|
||||
// Migrate channels: move "mention_only" to "group_trigger.mention_only"
|
||||
if channels, ok := m["channels"]; ok {
|
||||
if chMap, ok := channels.(map[string]any); ok {
|
||||
for _, ch := range chMap {
|
||||
if chVal, ok := ch.(map[string]any); ok {
|
||||
if mentionOnly, hasMention := chVal["mention_only"]; hasMention {
|
||||
delete(chVal, "mention_only")
|
||||
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
|
||||
gt["mention_only"] = mentionOnly
|
||||
} else {
|
||||
chVal["group_trigger"] = map[string]any{"mention_only": mentionOnly}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infer "enabled" field for models matching configV1.migrateModelEnabled behavior
|
||||
if modelList, ok := m["model_list"].([]any); ok {
|
||||
// Convert api_key → api_keys for each model
|
||||
for _, model := range modelList {
|
||||
if mVal, ok := model.(map[string]any); ok {
|
||||
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
|
||||
mVal["api_keys"] = ss
|
||||
delete(mVal, "api_key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infer enabled status
|
||||
for _, model := range modelList {
|
||||
if mVal, ok := model.(map[string]any); ok {
|
||||
// Skip if explicitly set
|
||||
if _, hasEnabled := mVal["enabled"]; hasEnabled {
|
||||
continue
|
||||
}
|
||||
// Models with API keys are considered enabled
|
||||
if apiKeys, hasAPIKeys := mVal["api_keys"]; hasAPIKeys {
|
||||
// Check for []any or []string
|
||||
hasKeys := false
|
||||
if keys, ok := apiKeys.([]any); ok {
|
||||
hasKeys = len(keys) > 0
|
||||
} else if keys, ok := apiKeys.([]string); ok {
|
||||
hasKeys = len(keys) > 0
|
||||
}
|
||||
if hasKeys {
|
||||
mVal["enabled"] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
// The reserved "local-model" entry is considered enabled
|
||||
if mVal["model_name"] == "local-model" {
|
||||
mVal["enabled"] = true
|
||||
}
|
||||
logger.Infof("model: %v", mVal)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Warnf("model_list is not a slice: %#v", m["model_list"])
|
||||
}
|
||||
|
||||
m["version"] = 2
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV2ToV3 converts a V2 config JSON to V3 format:
|
||||
// 1. Renames "channels" key to "channel_list"
|
||||
// 2. Converts flat-format channel entries to nested format (wrapping
|
||||
// channel-specific fields in "settings")
|
||||
// 3. Sets version to 3
|
||||
func migrateV2ToV3(m map[string]any) error {
|
||||
if !compareInt(m["version"], 2) {
|
||||
return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"])
|
||||
}
|
||||
|
||||
// Rename channels → channel_list
|
||||
if channels, ok := m["channels"]; ok {
|
||||
delete(m, "channels")
|
||||
|
||||
// Convert each channel from flat to nested format
|
||||
if chMap, ok := channels.(map[string]any); ok {
|
||||
for k, ch := range chMap {
|
||||
if chVal, ok := ch.(map[string]any); ok {
|
||||
chVal["type"] = k
|
||||
// If already has "settings" key, leave as-is
|
||||
if _, hasSettings := chVal["settings"]; hasSettings {
|
||||
continue
|
||||
}
|
||||
|
||||
// Migrate Onebot "group_trigger_prefix" → "group_trigger.prefixes"
|
||||
if gtp, hasGTP := chVal["group_trigger_prefix"]; hasGTP {
|
||||
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
|
||||
if _, hasPrefixes := gt["prefixes"]; !hasPrefixes {
|
||||
gt["prefixes"] = gtp
|
||||
}
|
||||
} else {
|
||||
chVal["group_trigger"] = map[string]any{"prefixes": gtp}
|
||||
}
|
||||
delete(chVal, "group_trigger_prefix")
|
||||
}
|
||||
|
||||
// Separate channel-specific fields into "settings"
|
||||
settings := make(map[string]any)
|
||||
for fieldKey, v := range chVal {
|
||||
if _, exists := BaseFieldNames[fieldKey]; !exists {
|
||||
settings[fieldKey] = v
|
||||
delete(chVal, fieldKey)
|
||||
}
|
||||
}
|
||||
if len(settings) > 0 {
|
||||
chVal["settings"] = settings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m["channel_list"] = channels
|
||||
}
|
||||
|
||||
m["version"] = CurrentVersion
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfigMap(path string) (map[string]any, error) {
|
||||
var m1, m2 map[string]any
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return m1, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
if err = json.Unmarshal(data, &m1); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
secPath := securityPath(path)
|
||||
data, err = os.ReadFile(secPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return m1, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read security config: %w", err)
|
||||
}
|
||||
if err = yaml.Unmarshal(data, &m2); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse security config: %w", err)
|
||||
}
|
||||
if m2["web"] != nil || m2["skills"] != nil {
|
||||
m3 := make(map[string]any)
|
||||
if m2["web"] != nil {
|
||||
m3["web"] = m2["web"]
|
||||
delete(m2, "web")
|
||||
}
|
||||
if m2["skills"] != nil {
|
||||
m3["skills"] = m2["skills"]
|
||||
delete(m2, "skills")
|
||||
if m, ok := m3["skills"].(map[string]any); ok {
|
||||
if m["clawhub"] != nil {
|
||||
m["registries"] = map[string]any{"clawhub": m["clawhub"]}
|
||||
delete(m, "clawhub")
|
||||
}
|
||||
}
|
||||
}
|
||||
m2["tools"] = m3
|
||||
}
|
||||
|
||||
// Handle model_list merging specially: m1 has array format, m2 has map format
|
||||
if mainML, hasMainML := m1["model_list"]; hasMainML {
|
||||
if secML, hasSecML := m2["model_list"]; hasSecML {
|
||||
if secMap, ok := secML.(map[string]any); ok {
|
||||
// JSON unmarshals arrays as []any, convert to []map[string]any
|
||||
var mainArr []any
|
||||
if rawArr, ok := mainML.([]any); ok {
|
||||
mainArr = make([]any, 0, len(rawArr))
|
||||
for _, item := range rawArr {
|
||||
if mVal, ok := item.(map[string]any); ok {
|
||||
mainArr = append(mainArr, mVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(mainArr) > 0 {
|
||||
// Merge array-style with map-style in-place
|
||||
err = mergeModelListsWithMap(mainArr, secMap)
|
||||
if err != nil {
|
||||
logger.Errorf("mergeModelListsWithMap error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
m1["model_list"] = mainArr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove model_list from m2 so mergeMap doesn't override the array with map
|
||||
delete(m2, "model_list")
|
||||
|
||||
m := mergeMap(m1, m2)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// mergeModelListsWithMap merges array-style model_list with map-style security model_list.
|
||||
// It generates indexed keys from model_name (like toNameIndex) and uses them
|
||||
// to look up security entries, falling back to ModelName if the indexed key doesn't exist.
|
||||
func mergeModelListsWithMap(mainML []any, secML map[string]any) error {
|
||||
// Build indexed keys like toNameIndex does
|
||||
indexedKeys := make(map[string]int)
|
||||
countMap := make(map[string]int)
|
||||
for i, m := range mainML {
|
||||
if mVal, ok := m.(map[string]any); ok {
|
||||
if name, hasName := mVal["model_name"]; hasName {
|
||||
nameStr := name.(string)
|
||||
index := countMap[nameStr]
|
||||
indexedKeys[fmt.Sprintf("%s:%d", nameStr, index)] = i
|
||||
if _, ok := indexedKeys[nameStr]; !ok {
|
||||
indexedKeys[nameStr] = i
|
||||
}
|
||||
countMap[nameStr]++
|
||||
} else {
|
||||
return fmt.Errorf("model_name is required: %#v", mVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range secML {
|
||||
if i, ok := indexedKeys[k]; ok {
|
||||
if vv, ok := v.(map[string]any); ok {
|
||||
if mVal, ok := mainML[i].(map[string]any); ok {
|
||||
mVal["api_keys"] = vv["api_keys"]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Warnf("model_name not found in main config: %s", k)
|
||||
}
|
||||
delete(secML, k)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMigration_Integration_LegacyConfigWithoutWorkspace tests the issue reported:
|
||||
@@ -74,6 +76,8 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
|
||||
if cfg.Agents.Defaults.Provider != "openai" {
|
||||
t.Errorf("Provider = %q, want %q (user's setting should be preserved)", cfg.Agents.Defaults.Provider, "openai")
|
||||
}
|
||||
|
||||
t.Logf("defaults: %v", cfg.Agents.Defaults)
|
||||
// Old "model" field is migrated to "model_name" field
|
||||
if cfg.Agents.Defaults.ModelName != "gpt-4o" {
|
||||
t.Errorf(
|
||||
@@ -100,11 +104,14 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify other config sections are preserved
|
||||
if !cfg.Channels.Telegram.Enabled {
|
||||
var tgCfg TelegramSettings
|
||||
bc := cfg.Channels.Get("telegram")
|
||||
if bc == nil || !bc.Enabled {
|
||||
t.Error("Telegram.Enabled should be true")
|
||||
}
|
||||
if cfg.Channels.Telegram.Token.String() != "test-token" {
|
||||
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token.String(), "test-token")
|
||||
bc.Decode(&tgCfg)
|
||||
if tgCfg.Token.String() != "test-token" {
|
||||
t.Errorf("Telegram.Token = %q, want %q", tgCfg.Token.String(), "test-token")
|
||||
}
|
||||
if cfg.Gateway.Port != 18790 {
|
||||
t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790)
|
||||
@@ -356,19 +363,21 @@ func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) {
|
||||
}
|
||||
|
||||
// Discord: mention_only should be migrated to group_trigger.mention_only
|
||||
if cfg.Channels.Discord.GroupTrigger.MentionOnly != true {
|
||||
discordBC := cfg.Channels.Get("discord")
|
||||
if !discordBC.GroupTrigger.MentionOnly {
|
||||
t.Error("Discord.GroupTrigger.MentionOnly should be true after migration")
|
||||
}
|
||||
|
||||
// OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes
|
||||
if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 {
|
||||
t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(cfg.Channels.OneBot.GroupTrigger.Prefixes))
|
||||
oneBotBC := cfg.Channels.Get("onebot")
|
||||
if len(oneBotBC.GroupTrigger.Prefixes) != 2 {
|
||||
t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(oneBotBC.GroupTrigger.Prefixes))
|
||||
} else {
|
||||
if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
|
||||
t.Errorf("Prefixes[0] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[0], "/")
|
||||
if oneBotBC.GroupTrigger.Prefixes[0] != "/" {
|
||||
t.Errorf("Prefixes[0] = %q, want %q", oneBotBC.GroupTrigger.Prefixes[0], "/")
|
||||
}
|
||||
if cfg.Channels.OneBot.GroupTrigger.Prefixes[1] != "!" {
|
||||
t.Errorf("Prefixes[1] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[1], "!")
|
||||
if oneBotBC.GroupTrigger.Prefixes[1] != "!" {
|
||||
t.Errorf("Prefixes[1] = %q, want %q", oneBotBC.GroupTrigger.Prefixes[1], "!")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,6 +587,7 @@ func TestMigration_PreservesExistingSecurityConfig(t *testing.T) {
|
||||
// Create a legacy config (version 0) with model_list and channel config
|
||||
// The model_list doesn't have api_keys, they should come from existing .security.yml
|
||||
legacyConfig := `{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "openai",
|
||||
@@ -641,20 +651,38 @@ web:
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Migrated config: %#v", cfg.Channels["telegram"])
|
||||
t.Logf("Migrated config settings: %v", string(cfg.Channels["telegram"].Settings))
|
||||
|
||||
// Verify that the migrated config has the existing security values
|
||||
// Telegram token should be preserved
|
||||
if cfg.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" {
|
||||
var tgCfg1 *TelegramSettings
|
||||
if bc := cfg.Channels.Get("telegram"); bc != nil {
|
||||
t.Logf("telegram settings: %v", string(bc.Settings))
|
||||
if decoded, e := bc.GetDecoded(); e == nil && decoded != nil {
|
||||
tgCfg1 = decoded.(*TelegramSettings)
|
||||
}
|
||||
}
|
||||
require.NotNil(t, tgCfg1)
|
||||
if tgCfg1.Token.String() != "existing-telegram-token-from-env" {
|
||||
t.Errorf("Telegram token was overwritten: got %q, want %q",
|
||||
cfg.Channels.Telegram.Token.String(), "existing-telegram-token-from-env")
|
||||
tgCfg1.Token.String(), "existing-telegram-token-from-env")
|
||||
}
|
||||
|
||||
// Discord token should be preserved (even though legacy config didn't have it)
|
||||
if cfg.Channels.Discord.Token.String() != "existing-discord-token-from-env" {
|
||||
var dcCfg1 *DiscordSettings
|
||||
if bc := cfg.Channels.Get("discord"); bc != nil {
|
||||
if decoded, e := bc.GetDecoded(); e == nil && decoded != nil {
|
||||
dcCfg1 = decoded.(*DiscordSettings)
|
||||
}
|
||||
}
|
||||
if dcCfg1.Token.String() != "existing-discord-token-from-env" {
|
||||
t.Errorf("Discord token was overwritten: got %q, want %q",
|
||||
cfg.Channels.Discord.Token.String(), "existing-discord-token-from-env")
|
||||
dcCfg1.Token.String(), "existing-discord-token-from-env")
|
||||
}
|
||||
|
||||
// Model API key should be preserved
|
||||
t.Logf("model_list: %#v", cfg.ModelList[0])
|
||||
if cfg.ModelList[0].APIKey() != "sk-existing-key-from-env" {
|
||||
t.Errorf("Model API key was overwritten: got %q, want %q",
|
||||
cfg.ModelList[0].APIKey(), "sk-existing-key-from-env")
|
||||
@@ -668,16 +696,30 @@ web:
|
||||
|
||||
// Reload the security config from disk to verify it wasn't corrupted
|
||||
reloadedSec := cfg
|
||||
t.Logf("reloadedSec started")
|
||||
err = loadSecurityConfig(cfg, securityPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reload security config: %v", err)
|
||||
}
|
||||
|
||||
if reloadedSec.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" {
|
||||
var tgCfgSec *TelegramSettings
|
||||
if bc := reloadedSec.Channels.Get("telegram"); bc != nil {
|
||||
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||
tgCfgSec = decoded.(*TelegramSettings)
|
||||
}
|
||||
}
|
||||
if tgCfgSec.Token.String() != "existing-telegram-token-from-env" {
|
||||
t.Errorf("Telegram settings: %v", tgCfgSec)
|
||||
t.Error("Telegram token not preserved in .security.yml file")
|
||||
}
|
||||
|
||||
if reloadedSec.Channels.Discord.Token.String() != "existing-discord-token-from-env" {
|
||||
var dcCfgSec *DiscordSettings
|
||||
if bc := reloadedSec.Channels.Get("discord"); bc != nil {
|
||||
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||
dcCfgSec = decoded.(*DiscordSettings)
|
||||
}
|
||||
}
|
||||
if dcCfgSec.Token.String() != "existing-discord-token-from-env" {
|
||||
t.Error("Discord token not preserved in .security.yml file")
|
||||
}
|
||||
}
|
||||
@@ -686,186 +728,174 @@ web:
|
||||
// V1 → V2 migration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestMigrateModelEnabled_APIKeysInferredEnabled verifies that models with API keys
|
||||
// are marked as enabled during V1→V2 migration.
|
||||
func TestMigrateModelEnabled_APIKeysInferredEnabled(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
{ModelName: "claude", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("sk-ant")},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
for _, m := range v1.ModelList {
|
||||
if !m.Enabled {
|
||||
t.Errorf("model %q with API key should be enabled", m.ModelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_LocalModelInferredEnabled verifies that the reserved
|
||||
// "local-model" entry is enabled even without API keys.
|
||||
func TestMigrateModelEnabled_LocalModelInferredEnabled(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1"},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
if !v1.ModelList[0].Enabled {
|
||||
t.Error("local-model should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_NoKeyStaysDisabled verifies that models without API keys
|
||||
// and not named "local-model" remain disabled.
|
||||
func TestMigrateModelEnabled_NoKeyStaysDisabled(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
{ModelName: "claude", Model: "anthropic/claude"},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
for _, m := range v1.ModelList {
|
||||
if m.Enabled {
|
||||
t.Errorf("model %q without API key should stay disabled", m.ModelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_ExplicitEnabledPreserved verifies that a model with
|
||||
// explicitly enabled=true is NOT overridden by the migration.
|
||||
func TestMigrateModelEnabled_ExplicitEnabledPreserved(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: true},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
if !v1.ModelList[0].Enabled {
|
||||
t.Error("explicitly enabled model should remain enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_ExplicitDisabledNotOverridden verifies that a model with
|
||||
// explicitly enabled=false and API keys gets enabled during migration.
|
||||
// Note: since Go's zero value for bool is false and JSON omitempty omits false,
|
||||
// migration cannot distinguish "explicitly false" from "field absent". Both cases
|
||||
// get the same inference treatment.
|
||||
func TestMigrateModelEnabled_ExplicitDisabledNotOverridden(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: false},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
// Even though Enabled was set to false, migration infers it as true because
|
||||
// the migration cannot distinguish from a missing field (both are zero value).
|
||||
if !v1.ModelList[0].Enabled {
|
||||
t.Error("model with API key should be enabled by migration inference")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateModelEnabled_Mixed verifies a mix of models.
|
||||
func TestMigrateModelEnabled_Mixed(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "with-key", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
{ModelName: "no-key", Model: "openai/gpt-4"},
|
||||
{ModelName: "local-model", Model: "vllm/custom"},
|
||||
{
|
||||
ModelName: "disabled-explicit",
|
||||
Model: "openai/gpt-4",
|
||||
APIKeys: SimpleSecureStrings("sk-test"),
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateModelEnabled()
|
||||
|
||||
assertEnabled := func(name string, want bool) {
|
||||
for _, m := range v1.ModelList {
|
||||
if m.ModelName == name {
|
||||
if m.Enabled != want {
|
||||
t.Errorf("model %q: Enabled=%v, want %v", name, m.Enabled, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("model %q not found", name)
|
||||
}
|
||||
|
||||
assertEnabled("with-key", true)
|
||||
assertEnabled("no-key", false)
|
||||
assertEnabled("local-model", true)
|
||||
assertEnabled("disabled-explicit", true) // false is zero value, migration infers from API key
|
||||
}
|
||||
|
||||
// TestMigrateChannelConfigs_DiscordMentionOnly verifies Discord mention_only migration.
|
||||
func TestMigrateChannelConfigs_DiscordMentionOnly(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
Channels: ChannelsConfig{
|
||||
Discord: DiscordConfig{
|
||||
MentionOnly: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateChannelConfigs()
|
||||
if !v1.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
t.Error("Discord GroupTrigger.MentionOnly should be set to true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateChannelConfigs_DiscordAlreadyMigrated is a no-op test.
|
||||
func TestMigrateChannelConfigs_DiscordAlreadyMigrated(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
Channels: ChannelsConfig{
|
||||
Discord: DiscordConfig{
|
||||
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateChannelConfigs()
|
||||
}
|
||||
|
||||
// TestMigrateChannelConfigs_OneBotPrefix verifies OneBot prefix migration.
|
||||
func TestMigrateChannelConfigs_OneBotPrefix(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
Channels: ChannelsConfig{
|
||||
OneBot: OneBotConfig{
|
||||
GroupTriggerPrefix: []string{"/"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
v1.migrateChannelConfigs()
|
||||
if len(v1.Channels.OneBot.GroupTrigger.Prefixes) != 1 || v1.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
|
||||
t.Errorf("OneBot GroupTrigger.Prefixes = %v, want [\"/\"]", v1.Channels.OneBot.GroupTrigger.Prefixes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateConfigV1_Combined verifies that configV1.Migrate applies both migrations.
|
||||
func TestMigrateConfigV1_Combined(t *testing.T) {
|
||||
v1 := &configV1{Config: Config{
|
||||
ModelList: []*ModelConfig{
|
||||
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
Discord: DiscordConfig{MentionOnly: true},
|
||||
},
|
||||
}}
|
||||
result, err := v1.Migrate()
|
||||
if err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
if !result.ModelList[0].Enabled {
|
||||
t.Error("model with API key should be enabled after V1→V2 migration")
|
||||
}
|
||||
if !result.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
t.Error("Discord mention_only should be migrated after V1→V2 migration")
|
||||
}
|
||||
}
|
||||
//// TestMigrateModelEnabled_APIKeysInferredEnabled verifies that models with API keys
|
||||
//// are marked as enabled during V1→V2 migration.
|
||||
//func TestMigrateModelEnabled_APIKeysInferredEnabled(t *testing.T) {
|
||||
// v1 := &configV1{Config: Config{
|
||||
// ModelList: []*ModelConfig{
|
||||
// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
// {ModelName: "claude", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("sk-ant")},
|
||||
// },
|
||||
// }}
|
||||
// v1.migrateModelEnabled()
|
||||
// for _, m := range v1.ModelList {
|
||||
// if !m.Enabled {
|
||||
// t.Errorf("model %q with API key should be enabled", m.ModelName)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// TestMigrateModelEnabled_LocalModelInferredEnabled verifies that the reserved
|
||||
//// "local-model" entry is enabled even without API keys.
|
||||
//func TestMigrateModelEnabled_LocalModelInferredEnabled(t *testing.T) {
|
||||
// v1 := &configV1{
|
||||
// ModelList: []*ModelConfig{
|
||||
// {ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1"},
|
||||
// },
|
||||
// }
|
||||
// v1.migrateModelEnabled()
|
||||
// if !v1.ModelList[0].Enabled {
|
||||
// t.Error("local-model should be enabled")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// TestMigrateModelEnabled_NoKeyStaysDisabled verifies that models without API keys
|
||||
//// and not named "local-model" remain disabled.
|
||||
//func TestMigrateModelEnabled_NoKeyStaysDisabled(t *testing.T) {
|
||||
// v1 := &configV1{
|
||||
// ModelList: []*ModelConfig{
|
||||
// {ModelName: "gpt-4", Model: "openai/gpt-4"},
|
||||
// {ModelName: "claude", Model: "anthropic/claude"},
|
||||
// },
|
||||
// }
|
||||
// v1.migrateModelEnabled()
|
||||
// for _, m := range v1.ModelList {
|
||||
// if m.Enabled {
|
||||
// t.Errorf("model %q without API key should stay disabled", m.ModelName)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// TestMigrateModelEnabled_ExplicitEnabledPreserved verifies that a model with
|
||||
//// explicitly enabled=true is NOT overridden by the migration.
|
||||
//func TestMigrateModelEnabled_ExplicitEnabledPreserved(t *testing.T) {
|
||||
// v1 := &configV1{Config: Config{
|
||||
// ModelList: []*ModelConfig{
|
||||
// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: true},
|
||||
// },
|
||||
// }}
|
||||
// v1.migrateModelEnabled()
|
||||
// if !v1.ModelList[0].Enabled {
|
||||
// t.Error("explicitly enabled model should remain enabled")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// TestMigrateModelEnabled_ExplicitDisabledNotOverridden verifies that a model with
|
||||
//// explicitly enabled=false and API keys gets enabled during migration.
|
||||
//// Note: since Go's zero value for bool is false and JSON omitempty omits false,
|
||||
//// migration cannot distinguish "explicitly false" from "field absent". Both cases
|
||||
//// get the same inference treatment.
|
||||
//func TestMigrateModelEnabled_ExplicitDisabledNotOverridden(t *testing.T) {
|
||||
// v1 := &configV1{Config: Config{
|
||||
// ModelList: []*ModelConfig{
|
||||
// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: false},
|
||||
// },
|
||||
// }}
|
||||
// v1.migrateModelEnabled()
|
||||
// // Even though Enabled was set to false, migration infers it as true because
|
||||
// // the migration cannot distinguish from a missing field (both are zero value).
|
||||
// if !v1.ModelList[0].Enabled {
|
||||
// t.Error("model with API key should be enabled by migration inference")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// TestMigrateModelEnabled_Mixed verifies a mix of models.
|
||||
//func TestMigrateModelEnabled_Mixed(t *testing.T) {
|
||||
// v1 := &configV1{Config: Config{
|
||||
// ModelList: []*ModelConfig{
|
||||
// {ModelName: "with-key", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
// {ModelName: "no-key", Model: "openai/gpt-4"},
|
||||
// {ModelName: "local-model", Model: "vllm/custom"},
|
||||
// {
|
||||
// ModelName: "disabled-explicit",
|
||||
// Model: "openai/gpt-4",
|
||||
// APIKeys: SimpleSecureStrings("sk-test"),
|
||||
// Enabled: false,
|
||||
// },
|
||||
// },
|
||||
// }}
|
||||
// v1.migrateModelEnabled()
|
||||
//
|
||||
// assertEnabled := func(name string, want bool) {
|
||||
// for _, m := range v1.ModelList {
|
||||
// if m.ModelName == name {
|
||||
// if m.Enabled != want {
|
||||
// t.Errorf("model %q: Enabled=%v, want %v", name, m.Enabled, want)
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// t.Errorf("model %q not found", name)
|
||||
// }
|
||||
//
|
||||
// assertEnabled("with-key", true)
|
||||
// assertEnabled("no-key", false)
|
||||
// assertEnabled("local-model", true)
|
||||
// assertEnabled("disabled-explicit", true) // false is zero value, migration infers from API key
|
||||
//}
|
||||
//
|
||||
//// TestMigrateChannelConfigs_DiscordMentionOnly verifies Discord mention_only migration.
|
||||
//func TestMigrateChannelConfigs_DiscordMentionOnly(t *testing.T) {
|
||||
// channels := ChannelsConfig{"discord": makeBaseChannelFromConfig(DiscordSettings{MentionOnly: true})}
|
||||
// v1 := &configV1{Config: Config{Channels: channels}}
|
||||
// v1.migrateChannelConfigs()
|
||||
// bc := v1.Channels.Get("discord")
|
||||
// if !bc.GroupTrigger.MentionOnly {
|
||||
// t.Error("Discord GroupTrigger.MentionOnly should be set to true")
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// TestMigrateChannelConfigs_DiscordAlreadyMigrated is a no-op test.
|
||||
//func TestMigrateChannelConfigs_DiscordAlreadyMigrated(t *testing.T) {
|
||||
// channels := ChannelsConfig{"discord": makeBaseChannelFromConfig(map[string]any{
|
||||
// "group_trigger": map[string]any{"mention_only": true},
|
||||
// })}
|
||||
// v1 := &configV1{Config: Config{Channels: channels}}
|
||||
// v1.migrateChannelConfigs()
|
||||
//}
|
||||
//
|
||||
//// TestMigrateChannelConfigs_OneBotPrefix verifies OneBot prefix migration.
|
||||
//func TestMigrateChannelConfigs_OneBotPrefix(t *testing.T) {
|
||||
// channels := ChannelsConfig{"onebot": makeBaseChannelFromConfig(OneBotSettings{GroupTriggerPrefix: []string{"/"}})}
|
||||
// v1 := &configV1{Config: Config{Channels: channels}}
|
||||
// v1.migrateChannelConfigs()
|
||||
// bc := v1.Channels.Get("onebot")
|
||||
// if len(bc.GroupTrigger.Prefixes) != 1 || bc.GroupTrigger.Prefixes[0] != "/" {
|
||||
// t.Errorf("OneBot GroupTrigger.Prefixes = %v, want [\"/\"]", bc.GroupTrigger.Prefixes)
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// TestMigrateConfigV1_Combined verifies that configV1.Migrate applies both migrations.
|
||||
//func TestMigrateConfigV1_Combined(t *testing.T) {
|
||||
// v1 := &configV1{Config: Config{
|
||||
// ModelList: []*ModelConfig{
|
||||
// {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
|
||||
// },
|
||||
// Channels: ChannelsConfig{"discord": makeBaseChannelFromConfig(DiscordSettings{MentionOnly: true})},
|
||||
// }}
|
||||
// result, err := v1.Migrate()
|
||||
// if err != nil {
|
||||
// t.Fatalf("Migrate: %v", err)
|
||||
// }
|
||||
//
|
||||
// if !result.ModelList[0].Enabled {
|
||||
// t.Error("model with API key should be enabled after V1→V2 migration")
|
||||
// }
|
||||
// dcResultBC := result.Channels.Get("discord")
|
||||
// if !dcResultBC.GroupTrigger.MentionOnly {
|
||||
// t.Error("Discord mention_only should be migrated after V1→V2 migration")
|
||||
// }
|
||||
//}
|
||||
|
||||
// TestLoadConfig_V1ToV2Migration verifies end-to-end V1→V2 config migration
|
||||
// through LoadConfig, including Enabled field inference and version bump.
|
||||
@@ -928,7 +958,8 @@ func TestLoadConfig_V1ToV2Migration(t *testing.T) {
|
||||
}
|
||||
|
||||
// Discord channel config should be migrated
|
||||
if !cfg.Channels.Discord.GroupTrigger.MentionOnly {
|
||||
dcMigBC := cfg.Channels.Get("discord")
|
||||
if !dcMigBC.GroupTrigger.MentionOnly {
|
||||
t.Error("Discord mention_only should be migrated to group_trigger.mention_only")
|
||||
}
|
||||
|
||||
@@ -959,8 +990,8 @@ func TestLoadConfig_V1ToV2Migration(t *testing.T) {
|
||||
if err := json.Unmarshal(saved, &versionCheck); err != nil {
|
||||
t.Fatalf("Unmarshal saved config: %v", err)
|
||||
}
|
||||
if versionCheck.Version != 2 {
|
||||
t.Errorf("saved config version = %d, want 2", versionCheck.Version)
|
||||
if versionCheck.Version != 3 {
|
||||
t.Errorf("saved config version = %d, want 3", versionCheck.Version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1033,7 @@ func TestLoadConfig_V1WithAPIKeysInferredEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, m := range cfg.ModelList {
|
||||
t.Logf("Model: %+v", m)
|
||||
if !m.Enabled {
|
||||
t.Errorf("model %q with API key in security file should be enabled", m.ModelName)
|
||||
}
|
||||
@@ -1039,8 +1071,8 @@ func TestLoadConfig_V2DirectLoad(t *testing.T) {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != 2 {
|
||||
t.Errorf("Version = %d, want 2", cfg.Version)
|
||||
if cfg.Version != 3 {
|
||||
t.Errorf("Version = %d, want 3", cfg.Version)
|
||||
}
|
||||
|
||||
gpt4, _ := cfg.GetModelConfig("gpt-4")
|
||||
@@ -1050,104 +1082,18 @@ func TestLoadConfig_V2DirectLoad(t *testing.T) {
|
||||
|
||||
claude, _ := cfg.GetModelConfig("claude")
|
||||
if claude.Enabled {
|
||||
t.Error("claude without enabled field should be false (no migration for V2)")
|
||||
t.Error("claude without enabled field should be false")
|
||||
}
|
||||
|
||||
// No backup should be created for V2 load
|
||||
// V2→V3 migration creates a backup
|
||||
entries, _ := os.ReadDir(tmpDir)
|
||||
foundBackup := false
|
||||
for _, e := range entries {
|
||||
if matched, _ := filepath.Match("config.json.*.bak", e.Name()); matched {
|
||||
t.Errorf("V2 load should not create backup, but found %q", e.Name())
|
||||
foundBackup = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V2 migration produces
|
||||
// correct Enabled fields and version.
|
||||
func TestLoadConfig_V0MigrateProducesV2(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
v0Config := `{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model": "openai/gpt-4",
|
||||
"api_key": "sk-test"
|
||||
},
|
||||
{
|
||||
"model_name": "claude",
|
||||
"model": "anthropic/claude"
|
||||
},
|
||||
{
|
||||
"model_name": "local-model",
|
||||
"model": "vllm/custom-model"
|
||||
}
|
||||
],
|
||||
"gateway": {"host": "127.0.0.1", "port": 18790}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Version != CurrentVersion {
|
||||
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
|
||||
}
|
||||
|
||||
// Check enabled status
|
||||
modelEnabled := func(name string) bool {
|
||||
m, err := cfg.GetModelConfig(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m.Enabled
|
||||
}
|
||||
|
||||
if !modelEnabled("gpt-4") {
|
||||
t.Error("gpt-4 with API key from V0 should be enabled")
|
||||
}
|
||||
if modelEnabled("claude") {
|
||||
t.Error("claude without API key from V0 should be disabled")
|
||||
}
|
||||
if !modelEnabled("local-model") {
|
||||
t.Error("local-model from V0 should be enabled")
|
||||
if !foundBackup {
|
||||
t.Error("V2→V3 migration should create backup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error.
|
||||
func TestLoadConfig_UnsupportedVersion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}`
|
||||
if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(configPath)
|
||||
if err == nil {
|
||||
t.Fatal("LoadConfig should return error for unsupported version")
|
||||
}
|
||||
if !containsString(err.Error(), "unsupported config version") {
|
||||
t.Errorf("error = %q, want 'unsupported config version'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
func searchString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+351
-572
@@ -6,560 +6,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{
|
||||
providerConfigV0: providerConfigV0{
|
||||
APIKey: "sk-test-key",
|
||||
APIBase: "https://custom.api.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "openai" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
|
||||
}
|
||||
if result[0].Model != "openai/gpt-5.4" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
|
||||
}
|
||||
if result[0].APIKey != "sk-test-key" {
|
||||
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
Anthropic: providerConfigV0{
|
||||
APIBase: "https://custom.anthropic.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "anthropic" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic")
|
||||
}
|
||||
if result[0].Model != "anthropic/claude-sonnet-4.6" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4.6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
LiteLLM: providerConfigV0{
|
||||
APIBase: "http://localhost:4000/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].ModelName != "litellm" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "litellm")
|
||||
}
|
||||
if result[0].Model != "litellm/auto" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "litellm/auto")
|
||||
}
|
||||
if result[0].APIBase != "http://localhost:4000/v1" {
|
||||
t.Errorf("APIBase = %q, want %q", result[0].APIBase, "http://localhost:4000/v1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}},
|
||||
Groq: providerConfigV0{APIKey: "groq-key"},
|
||||
Zhipu: providerConfigV0{APIKey: "zhipu-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("len(result) = %d, want 3", len(result))
|
||||
}
|
||||
|
||||
// Check that all providers are present
|
||||
found := make(map[string]bool)
|
||||
for _, mc := range result {
|
||||
found[mc.ModelName] = true
|
||||
}
|
||||
|
||||
for _, name := range []string{"openai", "groq", "zhipu"} {
|
||||
if !found[name] {
|
||||
t.Errorf("Missing provider %q in result", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Empty(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("len(result) = %d, want 0", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Nil(t *testing.T) {
|
||||
result := v0ConvertProvidersToModelList(nil)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("result = %v, want nil", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||
// This test verifies that when providers have at least one configured field,
|
||||
// they are converted. GitHubCopilot has ConnectMode set, Antigravity has AuthMethod.
|
||||
// Other providers have no configuration, so they won't be converted.
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}},
|
||||
LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
|
||||
Anthropic: providerConfigV0{APIKey: "key2"},
|
||||
OpenRouter: providerConfigV0{APIKey: "key3"},
|
||||
Groq: providerConfigV0{APIKey: "key4"},
|
||||
Zhipu: providerConfigV0{APIKey: "key5"},
|
||||
VLLM: providerConfigV0{APIKey: "key6"},
|
||||
Gemini: providerConfigV0{APIKey: "key7"},
|
||||
Nvidia: providerConfigV0{APIKey: "key8"},
|
||||
Ollama: providerConfigV0{APIKey: "key9"},
|
||||
Moonshot: providerConfigV0{APIKey: "key10"},
|
||||
ShengSuanYun: providerConfigV0{APIKey: "key11"},
|
||||
DeepSeek: providerConfigV0{APIKey: "key12"},
|
||||
Cerebras: providerConfigV0{APIKey: "key13"},
|
||||
Vivgrid: providerConfigV0{APIKey: "key14"},
|
||||
VolcEngine: providerConfigV0{APIKey: "key15"},
|
||||
GitHubCopilot: providerConfigV0{ConnectMode: "grpc"},
|
||||
Antigravity: providerConfigV0{AuthMethod: "oauth"},
|
||||
Qwen: providerConfigV0{APIKey: "key17"},
|
||||
Mistral: providerConfigV0{APIKey: "key18"},
|
||||
Avian: providerConfigV0{APIKey: "key19"},
|
||||
LongCat: providerConfigV0{APIKey: "key-longcat"},
|
||||
ModelScope: providerConfigV0{APIKey: "key-modelscope"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
// All 23 providers should be converted
|
||||
if len(result) != 23 {
|
||||
t.Errorf("len(result) = %d, want 23", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_Proxy(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{
|
||||
providerConfigV0: providerConfigV0{
|
||||
APIKey: "key",
|
||||
Proxy: "http://proxy:8080",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Proxy != "http://proxy:8080" {
|
||||
t.Errorf("Proxy = %q, want %q", result[0].Proxy, "http://proxy:8080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
Ollama: providerConfigV0{
|
||||
APIBase: "http://localhost:11434",
|
||||
RequestTimeout: 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].RequestTimeout != 300 {
|
||||
t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{
|
||||
providerConfigV0: providerConfigV0{
|
||||
AuthMethod: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 0 {
|
||||
t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for preserving user's configured model during migration
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-reasoner",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Should use user's model, not default
|
||||
if result[0].Model != "deepseek/deepseek-reasoner" {
|
||||
t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4-turbo",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Model != "openai/gpt-4-turbo" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-4-turbo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "claude", // alternative name
|
||||
Model: "claude-opus-4-20250514",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
Anthropic: providerConfigV0{APIKey: "sk-ant"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Model != "anthropic/claude-opus-4-20250514" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "qwen",
|
||||
Model: "qwen-plus",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
Qwen: providerConfigV0{APIKey: "sk-qwen"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
if result[0].Model != "qwen/qwen-plus" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "qwen/qwen-plus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "deepseek",
|
||||
Model: "", // no model specified
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Should use default model
|
||||
if result[0].Model != "deepseek/deepseek-chat" {
|
||||
t.Errorf("Model = %q, want %q (default)", result[0].Model, "deepseek/deepseek-chat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "deepseek",
|
||||
Model: "deepseek-reasoner",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}},
|
||||
DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("len(result) = %d, want 2", len(result))
|
||||
}
|
||||
|
||||
// Find each provider and verify model
|
||||
for _, mc := range result {
|
||||
switch mc.ModelName {
|
||||
case "openai":
|
||||
if mc.Model != "openai/gpt-5.4" {
|
||||
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
|
||||
}
|
||||
case "deepseek":
|
||||
if mc.Model != "deepseek/deepseek-reasoner" {
|
||||
t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
providerAlias string
|
||||
expectedModel string
|
||||
provider providerConfigV0
|
||||
}{
|
||||
{"gpt", "openai/gpt-4-custom", providerConfigV0{APIKey: "key"}},
|
||||
{"claude", "anthropic/claude-custom", providerConfigV0{APIKey: "key"}},
|
||||
{"doubao", "volcengine/doubao-custom", providerConfigV0{APIKey: "key"}},
|
||||
{"tongyi", "qwen/qwen-custom", providerConfigV0{APIKey: "key"}},
|
||||
{"kimi", "moonshot/kimi-custom", providerConfigV0{APIKey: "key"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.providerAlias, func(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: tt.providerAlias,
|
||||
Model: strings.TrimPrefix(
|
||||
tt.expectedModel,
|
||||
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
|
||||
),
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{},
|
||||
}
|
||||
|
||||
// Set the appropriate provider config
|
||||
switch tt.providerAlias {
|
||||
case "gpt":
|
||||
cfg.Providers.OpenAI = openAIProviderConfigV0{providerConfigV0: tt.provider}
|
||||
case "claude":
|
||||
cfg.Providers.Anthropic = tt.provider
|
||||
case "doubao":
|
||||
cfg.Providers.VolcEngine = tt.provider
|
||||
case "tongyi":
|
||||
cfg.Providers.Qwen = tt.provider
|
||||
case "kimi":
|
||||
cfg.Providers.Moonshot = tt.provider
|
||||
}
|
||||
|
||||
// Need to fix the model name in config
|
||||
cfg.Agents.Defaults.Model = strings.TrimPrefix(
|
||||
tt.expectedModel,
|
||||
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
|
||||
)
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Extract just the model ID part (after the first /)
|
||||
expectedModelID := tt.expectedModel
|
||||
if result[0].Model != expectedModelID {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, expectedModelID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test for backward compatibility: single provider without explicit provider field
|
||||
// This matches the legacy config pattern where users only set model, not provider
|
||||
|
||||
func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) {
|
||||
// This matches the user's actual config:
|
||||
// - No provider field set
|
||||
// - model = "glm-4.7"
|
||||
// - Only zhipu has API key configured
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "", // Not set
|
||||
Model: "glm-4.7",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
Zhipu: providerConfigV0{
|
||||
APIKey: "test-zhipu-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// ModelName should be the user's model value for backward compatibility
|
||||
if result[0].ModelName != "glm-4.7" {
|
||||
t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7")
|
||||
}
|
||||
|
||||
// Model should use the user's model with protocol prefix
|
||||
if result[0].Model != "zhipu/glm-4.7" {
|
||||
t.Errorf("Model = %q, want %q", result[0].Model, "zhipu/glm-4.7")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) {
|
||||
// When multiple providers are configured but no provider field is set,
|
||||
// the FIRST provider (in migration order) will use userModel as ModelName
|
||||
// for backward compatibility with legacy implicit provider selection
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "", // Not set
|
||||
Model: "some-model",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}},
|
||||
Zhipu: providerConfigV0{APIKey: "zhipu-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("len(result) = %d, want 2", len(result))
|
||||
}
|
||||
|
||||
// The first provider (OpenAI in migration order) should use userModel as ModelName
|
||||
// This ensures GetModelConfig("some-model") will find it
|
||||
if result[0].ModelName != "some-model" {
|
||||
t.Errorf("First provider ModelName = %q, want %q", result[0].ModelName, "some-model")
|
||||
}
|
||||
|
||||
// Other providers should use provider name as ModelName
|
||||
if result[1].ModelName != "zhipu" {
|
||||
t.Errorf("Second provider ModelName = %q, want %q", result[1].ModelName, "zhipu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
|
||||
// Edge case: no provider, no model
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "",
|
||||
Model: "",
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
Zhipu: providerConfigV0{APIKey: "zhipu-key"},
|
||||
},
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("len(result) = %d, want 1", len(result))
|
||||
}
|
||||
|
||||
// Should use default provider name since no model is specified
|
||||
if result[0].ModelName != "zhipu" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu")
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for buildModelWithProtocol helper function
|
||||
// Tests for buildModelWithProtocol helper function.
|
||||
|
||||
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
|
||||
result := buildModelWithProtocol("openai", "gpt-5.4")
|
||||
@@ -586,33 +40,358 @@ func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test for legacy config with protocol prefix in model name
|
||||
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
|
||||
cfg := &configV0{
|
||||
Agents: agentsConfigV0{
|
||||
Defaults: agentDefaultsV0{
|
||||
Provider: "", // No explicit provider
|
||||
Model: "openrouter/auto", // Model already has protocol prefix
|
||||
// ---------------------------------------------------------------------------
|
||||
// V0/V1/V2 → V3 migration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V3 migration produces
|
||||
// correct Enabled fields and version.
|
||||
func TestLoadConfig_V0MigrateProducesV2(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
v0Config := `{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model": "openai/gpt-4",
|
||||
"api_key": "sk-test"
|
||||
},
|
||||
},
|
||||
Providers: providersConfigV0{
|
||||
OpenRouter: providerConfigV0{APIKey: "sk-or-test"},
|
||||
},
|
||||
{
|
||||
"model_name": "claude",
|
||||
"model": "anthropic/claude"
|
||||
},
|
||||
{
|
||||
"model_name": "local-model",
|
||||
"model": "vllm/custom-model"
|
||||
}
|
||||
],
|
||||
"gateway": {"host": "127.0.0.1", "port": 18790}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
result := v0ConvertProvidersToModelList(cfg)
|
||||
|
||||
if len(result) < 1 {
|
||||
t.Fatalf("len(result) = %d, want at least 1", len(result))
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
// First provider should use userModel as ModelName for backward compatibility
|
||||
if result[0].ModelName != "openrouter/auto" {
|
||||
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto")
|
||||
if cfg.Version != CurrentVersion {
|
||||
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
|
||||
}
|
||||
|
||||
// Model should NOT have duplicated prefix
|
||||
if result[0].Model != "openrouter/auto" {
|
||||
t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto")
|
||||
// Check enabled status
|
||||
modelEnabled := func(name string) bool {
|
||||
m, err := cfg.GetModelConfig(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return m.Enabled
|
||||
}
|
||||
|
||||
if !modelEnabled("gpt-4") {
|
||||
t.Error("gpt-4 with API key from V0 should be enabled")
|
||||
}
|
||||
if modelEnabled("claude") {
|
||||
t.Error("claude without API key from V0 should be disabled")
|
||||
}
|
||||
if !modelEnabled("local-model") {
|
||||
t.Error("local-model from V0 should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error.
|
||||
func TestLoadConfig_UnsupportedVersion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}`
|
||||
if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(configPath)
|
||||
if err == nil {
|
||||
t.Fatal("LoadConfig should return error for unsupported version")
|
||||
}
|
||||
if !containsString(err.Error(), "unsupported config version") {
|
||||
t.Errorf("error = %q, want 'unsupported config version'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
func searchString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestMigrateV0ToV3 verifies V0 (legacy, no version) → V3 migration.
|
||||
// V0 configs use the old providers format without model_list.
|
||||
func TestMigrateV0ToV3(t *testing.T) {
|
||||
// V0 config: no version field, uses legacy providers
|
||||
v0Config := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-4"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"api_key": "sk-test123",
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"token": "bot-token"
|
||||
},
|
||||
"discord": {
|
||||
"mention_only": true
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600))
|
||||
m, err := loadConfigMap(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = migrateV0ToV1(m)
|
||||
require.NoError(t, err)
|
||||
err = migrateV1ToV2(m)
|
||||
require.NoError(t, err)
|
||||
err = migrateV2ToV3(m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Version should be set to CurrentVersion
|
||||
require.Equal(t, CurrentVersion, m["version"])
|
||||
|
||||
// Providers should be converted to model_list
|
||||
modelList, ok := m["model_list"].([]any)
|
||||
require.True(t, ok, "model_list should exist")
|
||||
require.NotEmpty(t, modelList, "model_list should not be empty")
|
||||
|
||||
t.Logf("modelList: %+v", modelList)
|
||||
// First model should be the user's configured provider with user's model
|
||||
firstModel := modelList[0].(map[string]any)
|
||||
require.Equal(t, "openai", firstModel["model_name"])
|
||||
require.Equal(t, "openai/gpt-4", firstModel["model"])
|
||||
// api_key is converted to api_keys during migration
|
||||
require.Contains(t, firstModel, "api_keys", "api_keys should exist")
|
||||
|
||||
// Channels should be converted to nested format with channel_list
|
||||
channelList, ok := m["channel_list"].(map[string]any)
|
||||
require.True(t, ok, "channel_list should exist")
|
||||
require.NotContains(t, m, "channels", "old 'channels' key should be removed")
|
||||
|
||||
// telegram channel should have settings
|
||||
telegram := channelList["telegram"].(map[string]any)
|
||||
require.Equal(t, "telegram", telegram["type"])
|
||||
require.Contains(t, telegram, "settings", "telegram should have settings")
|
||||
settings := telegram["settings"].(map[string]any)
|
||||
require.Equal(t, "bot-token", settings["token"])
|
||||
|
||||
// discord channel should have group_trigger and mention_only in group_trigger
|
||||
discord := channelList["discord"].(map[string]any)
|
||||
require.Equal(t, "discord", discord["type"])
|
||||
discordGroupTrigger := discord["group_trigger"].(map[string]any)
|
||||
require.Equal(t, true, discordGroupTrigger["mention_only"])
|
||||
}
|
||||
|
||||
// TestMigrateV0ToV3_WithExistingModelList preserves existing model_list when present.
|
||||
func TestMigrateV0ToV3_WithExistingModelList(t *testing.T) {
|
||||
v0Config := `{
|
||||
"model_list": [
|
||||
{"model_name": "custom", "model": "openai/custom-model", "api_key": "sk-existing"}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {"token": "bot123"}
|
||||
}
|
||||
}`
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(v0Config), 0o600))
|
||||
m, err := loadConfigMap(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = migrateV0ToV1(m)
|
||||
require.NoError(t, err)
|
||||
err = migrateV1ToV2(m)
|
||||
require.NoError(t, err)
|
||||
err = migrateV2ToV3(m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Existing model_list should be preserved (not overridden by providers)
|
||||
modelList := m["model_list"].([]any)
|
||||
require.Len(t, modelList, 1)
|
||||
firstModel := modelList[0].(map[string]any)
|
||||
require.Equal(t, "custom", firstModel["model_name"])
|
||||
}
|
||||
|
||||
// TestMigrateV1ToV3 verifies V1 → V3 migration.
|
||||
// V1 uses flat channel format without "settings" wrapper.
|
||||
func TestMigrateV1ToV3(t *testing.T) {
|
||||
v1Config := `{
|
||||
"version": 1,
|
||||
"model_list": [
|
||||
{"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-test"}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"token": "bot-token",
|
||||
"base_url": "https://custom.api.com"
|
||||
},
|
||||
"discord": {
|
||||
"mention_only": true,
|
||||
"proxy": "socks5://localhost:1080"
|
||||
},
|
||||
"onebot": {
|
||||
"ws_url": "ws://localhost:3001",
|
||||
"group_trigger_prefix": ["/"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
|
||||
m, err := loadConfigMap(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = migrateV1ToV2(m)
|
||||
require.NoError(t, err)
|
||||
err = migrateV2ToV3(m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Version should be set to CurrentVersion
|
||||
require.Equal(t, CurrentVersion, m["version"])
|
||||
|
||||
// Channels should be converted to nested format
|
||||
channelList, ok := m["channel_list"].(map[string]any)
|
||||
require.True(t, ok, "channel_list should exist")
|
||||
require.NotContains(t, m, "channels", "old 'channels' key should be removed")
|
||||
|
||||
// telegram: flat fields moved to settings
|
||||
telegram := channelList["telegram"].(map[string]any)
|
||||
require.Equal(t, "telegram", telegram["type"])
|
||||
tgSettings := telegram["settings"].(map[string]any)
|
||||
require.Equal(t, "bot-token", tgSettings["token"])
|
||||
require.Equal(t, "https://custom.api.com", tgSettings["base_url"])
|
||||
|
||||
// discord: mention_only should be moved to group_trigger
|
||||
discord := channelList["discord"].(map[string]any)
|
||||
require.Equal(t, "discord", discord["type"])
|
||||
require.Contains(t, discord, "group_trigger", "mention_only should be migrated to group_trigger")
|
||||
gt := discord["group_trigger"].(map[string]any)
|
||||
require.Equal(t, true, gt["mention_only"])
|
||||
discordSettings := discord["settings"].(map[string]any)
|
||||
require.Equal(t, "socks5://localhost:1080", discordSettings["proxy"])
|
||||
|
||||
// onebot: group_trigger_prefix should be moved to group_trigger.prefixes
|
||||
onebot := channelList["onebot"].(map[string]any)
|
||||
require.Equal(t, "onebot", onebot["type"])
|
||||
obGroupTrigger := onebot["group_trigger"].(map[string]any)
|
||||
require.Equal(
|
||||
t,
|
||||
[]any{"/"},
|
||||
obGroupTrigger["prefixes"],
|
||||
"group_trigger_prefix should be moved to group_trigger.prefixes",
|
||||
)
|
||||
obSettings := onebot["settings"].(map[string]any)
|
||||
require.Equal(t, "ws://localhost:3001", obSettings["ws_url"])
|
||||
}
|
||||
|
||||
// TestMigrateV1ToV3_ApiKeyConversion verifies api_key → api_keys conversion.
|
||||
func TestMigrateV1ToV3_ApiKeyConversion(t *testing.T) {
|
||||
v1Config := `{
|
||||
"version": 1,
|
||||
"model_list": [
|
||||
{"model_name": "gpt-4", "model": "openai/gpt-4", "api_key": "sk-single"},
|
||||
{"model_name": "no-key", "model": "openai/no-key"}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {"token": "bot"}
|
||||
}
|
||||
}`
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
|
||||
m, err := loadConfigMap(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = migrateV1ToV2(m)
|
||||
require.NoError(t, err)
|
||||
err = migrateV2ToV3(m)
|
||||
require.NoError(t, err)
|
||||
|
||||
// api_key should be converted to api_keys array
|
||||
modelList := m["model_list"].([]any)
|
||||
firstModel := modelList[0].(map[string]any)
|
||||
require.NotContains(t, firstModel, "api_key", "api_key should be removed")
|
||||
require.Contains(t, firstModel, "api_keys", "api_keys should exist")
|
||||
// api_keys can be []string or []any depending on how it was set
|
||||
if apiKeys, ok := firstModel["api_keys"].([]string); ok {
|
||||
require.Len(t, apiKeys, 1)
|
||||
require.Equal(t, "sk-single", apiKeys[0])
|
||||
} else if apiKeys, ok := firstModel["api_keys"].([]any); ok {
|
||||
require.Len(t, apiKeys, 1)
|
||||
require.Equal(t, "sk-single", apiKeys[0])
|
||||
} else {
|
||||
t.Fatalf("api_keys has unexpected type: %T", firstModel["api_keys"])
|
||||
}
|
||||
|
||||
// Model without api_key should not have api_keys added
|
||||
secondModel := modelList[1].(map[string]any)
|
||||
require.NotContains(t, secondModel, "api_key")
|
||||
require.NotContains(t, secondModel, "api_keys")
|
||||
}
|
||||
|
||||
// TestMigrateV1ToV3_AlreadyNestedFormat leaves already-nested channels unchanged.
|
||||
func TestMigrateV1ToV3_AlreadyNestedFormat(t *testing.T) {
|
||||
v1Config := `{
|
||||
"version": 1,
|
||||
"model_list": [
|
||||
{"model_name": "gpt-4", "model": "openai/gpt-4"}
|
||||
],
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"type": "telegram",
|
||||
"settings": {
|
||||
"token": "bot-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte(v1Config), 0o600))
|
||||
m, err := loadConfigMap(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = migrateV1ToV2(m)
|
||||
require.NoError(t, err)
|
||||
err = migrateV2ToV3(m)
|
||||
require.NoError(t, err)
|
||||
|
||||
channelList := m["channel_list"].(map[string]any)
|
||||
telegram := channelList["telegram"].(map[string]any)
|
||||
// Should not be double-wrapped
|
||||
require.Equal(t, "telegram", telegram["type"])
|
||||
settings := telegram["settings"].(map[string]any)
|
||||
require.Equal(t, "bot-token", settings["token"])
|
||||
// Should NOT have nested settings inside settings
|
||||
require.NotContains(t, settings, "settings")
|
||||
}
|
||||
|
||||
@@ -144,42 +144,6 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDefaultsV0_JSON_BackwardCompat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "new model_name field",
|
||||
json: `{"model_name": "gpt4"}`,
|
||||
wantName: "gpt4",
|
||||
},
|
||||
{
|
||||
name: "old model field",
|
||||
json: `{"model": "gpt4"}`,
|
||||
wantName: "gpt4",
|
||||
},
|
||||
{
|
||||
name: "both fields - model_name wins",
|
||||
json: `{"model_name": "new", "model": "old"}`,
|
||||
wantName: "new",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var defaults agentDefaultsV0
|
||||
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
|
||||
t.Fatalf("Unmarshal error: %v", err)
|
||||
}
|
||||
if got := defaults.GetModelName(); got != tt.wantName {
|
||||
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+67
-2
@@ -30,11 +30,12 @@ func securityPath(configPath string) string {
|
||||
}
|
||||
|
||||
// loadSecurityConfig loads the security configuration from security.yml
|
||||
// Returns an empty SecurityConfig if the file doesn't exist
|
||||
// and merges secure field values into the config.
|
||||
func loadSecurityConfig(cfg *Config, securityPath string) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(securityPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -43,10 +44,58 @@ func loadSecurityConfig(cfg *Config, securityPath string) error {
|
||||
return fmt.Errorf("failed to read security config: %w", err)
|
||||
}
|
||||
|
||||
// Save existing channels and ModelList before unmarshal
|
||||
savedChannels := make(ChannelsConfig, len(cfg.Channels))
|
||||
for name, bc := range cfg.Channels {
|
||||
savedChannels[name] = bc
|
||||
}
|
||||
// savedModelList := cfg.ModelList
|
||||
|
||||
// Parse YAML into a yaml.Node tree to extract channels node
|
||||
var rootNode yaml.Node
|
||||
if err := yaml.Unmarshal(data, &rootNode); err != nil {
|
||||
return fmt.Errorf("failed to parse security config: %w", err)
|
||||
}
|
||||
|
||||
// Extract channels node (support both 'channels' and 'channel_list' keys)
|
||||
var channelsNode *yaml.Node
|
||||
if len(rootNode.Content) > 0 {
|
||||
content := rootNode.Content[0].Content
|
||||
for i := 0; i < len(content); i += 2 {
|
||||
if i+1 < len(content) {
|
||||
key := content[i].Value
|
||||
if key == "channels" || key == "channel_list" {
|
||||
channelsNode = content[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal non-channel fields from security.yml
|
||||
// This will resolve encrypted values for model_list, tools, etc.
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("failed to parse security config: %w", err)
|
||||
}
|
||||
|
||||
// Restore channels from saved, then manually merge from security.yml
|
||||
cfg.Channels = make(ChannelsConfig)
|
||||
for name, savedBC := range savedChannels {
|
||||
cfg.Channels[name] = savedBC
|
||||
}
|
||||
|
||||
// If we found a channels node in security.yml, merge it into existing channels
|
||||
if channelsNode != nil {
|
||||
if err := cfg.Channels.UnmarshalYAML(channelsNode); err != nil {
|
||||
return fmt.Errorf("failed to merge channels from security config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore ModelList if yaml.Unmarshal couldn't parse it (keyed format in security.yml)
|
||||
//if len(cfg.ModelList) == 0 && len(savedModelList) > 0 {
|
||||
// cfg.ModelList = savedModelList
|
||||
//}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -121,9 +170,25 @@ func collectSensitive(v reflect.Value, values *[]string) {
|
||||
|
||||
t := v.Type()
|
||||
|
||||
// Channel: use CollectSensitiveValues() method
|
||||
if t == reflect.TypeOf(Channel{}) {
|
||||
if method := v.MethodByName("CollectSensitiveValues"); method.IsValid() {
|
||||
results := method.Call(nil)
|
||||
if len(results) > 0 {
|
||||
if vals, ok := results[0].Interface().([]string); ok {
|
||||
*values = append(*values, vals...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SecureString: collect via String() method (defined on *SecureString)
|
||||
if t == reflect.TypeOf(SecureString{}) {
|
||||
result := v.Addr().MethodByName("String").Call(nil)
|
||||
// Create a new pointer to make it addressable for method calls
|
||||
ptr := reflect.New(t)
|
||||
ptr.Elem().Set(v)
|
||||
result := ptr.MethodByName("String").Call(nil)
|
||||
if len(result) > 0 {
|
||||
if s := result[0].String(); s != "" {
|
||||
*values = append(*values, s)
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
||||
"model_name": "test-model",
|
||||
"model": "openai/test-model",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"api_key": "sk-from-config-json-direct"
|
||||
"api_keys": ["sk-from-config-json-direct"]
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
@@ -108,7 +108,13 @@ skills:
|
||||
assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKey())
|
||||
|
||||
// Verify channel token from config.json takes precedence
|
||||
assert.Equal(t, "token-from-security-yml", cfg.Channels.Telegram.Token.String())
|
||||
var tgTokenCfg *TelegramSettings
|
||||
if bc := cfg.Channels.Get("telegram"); bc != nil {
|
||||
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||
tgTokenCfg = decoded.(*TelegramSettings)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "token-from-security-yml", tgTokenCfg.Token.String())
|
||||
|
||||
assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKeys[0].String())
|
||||
|
||||
@@ -350,68 +356,95 @@ skills:
|
||||
assert.Equal(t, "sk-model-from-file-12345", cfg.ModelList[0].APIKey())
|
||||
t.Logf("Model APIKey(): %s", cfg.ModelList[0].APIKey())
|
||||
|
||||
// Helper function to decode channel settings
|
||||
decodeChannel := func(name string) any {
|
||||
bc := cfg.Channels.Get(name)
|
||||
if bc == nil {
|
||||
return nil
|
||||
}
|
||||
decoded, _ := bc.GetDecoded()
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Helper to get SecureString value
|
||||
secureStr := func(s SecureString) string {
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// Verify Channel tokens via Key() methods
|
||||
// Telegram
|
||||
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token.String())
|
||||
t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String())
|
||||
tgSec := decodeChannel("telegram")
|
||||
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", secureStr(tgSec.(*TelegramSettings).Token))
|
||||
t.Logf("Telegram Token(): %s", secureStr(tgSec.(*TelegramSettings).Token))
|
||||
|
||||
// Feishu
|
||||
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String())
|
||||
assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey.String())
|
||||
assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken.String())
|
||||
t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String())
|
||||
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String())
|
||||
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String())
|
||||
feiSec := decodeChannel("feishu")
|
||||
assert.Equal(t, "feishu_test_app_secret", secureStr(feiSec.(*FeishuSettings).AppSecret))
|
||||
assert.Equal(t, "feishu_test_encrypt_key", secureStr(feiSec.(*FeishuSettings).EncryptKey))
|
||||
assert.Equal(t, "feishu_test_verification_token", secureStr(feiSec.(*FeishuSettings).VerificationToken))
|
||||
t.Logf("Feishu AppSecret(): %s", secureStr(feiSec.(*FeishuSettings).AppSecret))
|
||||
t.Logf("Feishu EncryptKey(): %s", secureStr(feiSec.(*FeishuSettings).EncryptKey))
|
||||
t.Logf("Feishu VerificationToken(): %s", secureStr(feiSec.(*FeishuSettings).VerificationToken))
|
||||
|
||||
// Discord
|
||||
assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token.String())
|
||||
t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token.String())
|
||||
discSec := decodeChannel("discord")
|
||||
assert.Equal(t, "discord_test_bot_token_xyz", secureStr(discSec.(*DiscordSettings).Token))
|
||||
t.Logf("Discord Token(): %s", secureStr(discSec.(*DiscordSettings).Token))
|
||||
|
||||
// DingTalk
|
||||
assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret.String())
|
||||
t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret.String())
|
||||
dtSec := decodeChannel("dingtalk")
|
||||
assert.Equal(t, "dingtalk_test_client_secret", secureStr(dtSec.(*DingTalkSettings).ClientSecret))
|
||||
t.Logf("DingTalk ClientSecret(): %s", secureStr(dtSec.(*DingTalkSettings).ClientSecret))
|
||||
|
||||
// Slack
|
||||
assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken.String())
|
||||
assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken.String())
|
||||
t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken.String())
|
||||
t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken.String())
|
||||
slSec := decodeChannel("slack")
|
||||
assert.Equal(t, "xoxb-slack-bot-token-123", secureStr(slSec.(*SlackSettings).BotToken))
|
||||
assert.Equal(t, "xapp-slack-app-token-456", secureStr(slSec.(*SlackSettings).AppToken))
|
||||
t.Logf("Slack BotToken(): %s", secureStr(slSec.(*SlackSettings).BotToken))
|
||||
t.Logf("Slack AppToken(): %s", secureStr(slSec.(*SlackSettings).AppToken))
|
||||
|
||||
// Matrix
|
||||
assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken.String())
|
||||
t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken.String())
|
||||
matSec := decodeChannel("matrix")
|
||||
assert.Equal(t, "matrix_test_access_token", secureStr(matSec.(*MatrixSettings).AccessToken))
|
||||
t.Logf("Matrix AccessToken(): %s", secureStr(matSec.(*MatrixSettings).AccessToken))
|
||||
|
||||
// LINE
|
||||
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String())
|
||||
assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken.String())
|
||||
t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String())
|
||||
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.String())
|
||||
lineSec := decodeChannel("line")
|
||||
assert.Equal(t, "line_test_channel_secret", secureStr(lineSec.(*LINESettings).ChannelSecret))
|
||||
assert.Equal(t, "line_test_channel_access_token", secureStr(lineSec.(*LINESettings).ChannelAccessToken))
|
||||
t.Logf("LINE ChannelSecret(): %s", secureStr(lineSec.(*LINESettings).ChannelSecret))
|
||||
t.Logf("LINE ChannelAccessToken(): %s", secureStr(lineSec.(*LINESettings).ChannelAccessToken))
|
||||
|
||||
// OneBot
|
||||
assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken.String())
|
||||
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken.String())
|
||||
obSec := decodeChannel("onebot")
|
||||
assert.Equal(t, "onebot_test_access_token", secureStr(obSec.(*OneBotSettings).AccessToken))
|
||||
t.Logf("OneBot AccessToken(): %s", secureStr(obSec.(*OneBotSettings).AccessToken))
|
||||
|
||||
// WeCom
|
||||
assert.Equal(t, "test_wecom_bot_id", cfg.Channels.WeCom.BotID)
|
||||
assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret.String())
|
||||
t.Logf("WeCom BotID: %s", cfg.Channels.WeCom.BotID)
|
||||
t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret.String())
|
||||
wcSec := decodeChannel("wecom")
|
||||
assert.Equal(t, "test_wecom_bot_id", wcSec.(*WeComSettings).BotID)
|
||||
assert.Equal(t, "wecom_test_secret", secureStr(wcSec.(*WeComSettings).Secret))
|
||||
t.Logf("WeCom BotID: %s", wcSec.(*WeComSettings).BotID)
|
||||
t.Logf("WeCom Secret(): %s", secureStr(wcSec.(*WeComSettings).Secret))
|
||||
|
||||
// Pico
|
||||
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token.String())
|
||||
t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token.String())
|
||||
picoSec := decodeChannel("pico")
|
||||
assert.Equal(t, "pico_test_token", secureStr(picoSec.(*PicoSettings).Token))
|
||||
t.Logf("Pico Token(): %s", secureStr(picoSec.(*PicoSettings).Token))
|
||||
|
||||
// IRC
|
||||
assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password.String())
|
||||
assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword.String())
|
||||
assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword.String())
|
||||
t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password.String())
|
||||
t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword.String())
|
||||
t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword.String())
|
||||
ircSec := decodeChannel("irc")
|
||||
assert.Equal(t, "irc_test_password", secureStr(ircSec.(*IRCSettings).Password))
|
||||
assert.Equal(t, "irc_test_nickserv_password", secureStr(ircSec.(*IRCSettings).NickServPassword))
|
||||
assert.Equal(t, "irc_test_sasl_password", secureStr(ircSec.(*IRCSettings).SASLPassword))
|
||||
t.Logf("IRC Password(): %s", secureStr(ircSec.(*IRCSettings).Password))
|
||||
t.Logf("IRC NickServPassword(): %s", secureStr(ircSec.(*IRCSettings).NickServPassword))
|
||||
t.Logf("IRC SASLPassword(): %s", secureStr(ircSec.(*IRCSettings).SASLPassword))
|
||||
|
||||
// QQ
|
||||
assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret.String())
|
||||
t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret.String())
|
||||
qqSec := decodeChannel("qq")
|
||||
assert.Equal(t, "qq_test_app_secret", secureStr(qqSec.(*QQSettings).AppSecret))
|
||||
t.Logf("QQ AppSecret(): %s", secureStr(qqSec.(*QQSettings).AppSecret))
|
||||
|
||||
// Verify Web tool API keys
|
||||
assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey())
|
||||
|
||||
+79
-34
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func TestSecurityConfig(t *testing.T) {
|
||||
t.Run("LoadNonExistent", func(t *testing.T) {
|
||||
sec := &Config{}
|
||||
sec := &Config{Channels: make(ChannelsConfig)}
|
||||
err := loadSecurityConfig(sec, "/nonexistent/.security.yml")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, sec)
|
||||
@@ -75,6 +75,7 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) {
|
||||
secPath := filepath.Join(tmpDir, SecurityConfigFile)
|
||||
|
||||
original := &Config{
|
||||
Version: CurrentVersion,
|
||||
ModelList: SecureModelList{
|
||||
{
|
||||
ModelName: "model1",
|
||||
@@ -103,29 +104,38 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Channels: ChannelsConfig{
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: true,
|
||||
Token: *NewSecureString("telegram_token"),
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: true,
|
||||
AppID: "feishu_app_id",
|
||||
AppSecret: *NewSecureString("feishu_app_secret"),
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: true,
|
||||
Token: *NewSecureString("discord_token"),
|
||||
},
|
||||
QQ: QQConfig{
|
||||
Enabled: true,
|
||||
AppSecret: *NewSecureString("qq_app_secret"),
|
||||
},
|
||||
PicoClient: PicoClientConfig{
|
||||
Enabled: true,
|
||||
Token: *NewSecureString("pico_client_token"),
|
||||
},
|
||||
},
|
||||
Channels: func() ChannelsConfig {
|
||||
chs := make(ChannelsConfig)
|
||||
type def struct {
|
||||
name string
|
||||
raw string // raw JSON with actual secure values (bypasses SecureString.MarshalJSON)
|
||||
}
|
||||
for _, d := range []def{
|
||||
{"telegram", `{"enabled":true,"settings":{"token":"telegram_token"}}`},
|
||||
{"feishu", `{"enabled":true,"settings":{"app_id":"feishu_app_id","app_secret":"feishu_app_secret"}}`},
|
||||
{"discord", `{"enabled":true,"settings":{"token":"discord_token"}}`},
|
||||
{"qq", `{"enabled":true,"settings":{"app_secret":"qq_app_secret"}}`},
|
||||
{"pico_client", `{"enabled":true,"settings":{"token":"pico_client_token"}}`},
|
||||
} {
|
||||
bc := &Channel{}
|
||||
json.Unmarshal([]byte(d.raw), bc)
|
||||
bc.Type = d.name
|
||||
switch bc.Type {
|
||||
case "qq":
|
||||
bc.Decode(&QQSettings{})
|
||||
case "telegram":
|
||||
bc.Decode(&TelegramSettings{})
|
||||
case "discord":
|
||||
bc.Decode(&DiscordSettings{})
|
||||
case "feishu":
|
||||
bc.Decode(&FeishuSettings{})
|
||||
case "pico_client":
|
||||
bc.Decode(&PicoClientSettings{})
|
||||
}
|
||||
chs[d.name] = bc
|
||||
}
|
||||
return chs
|
||||
}(),
|
||||
}
|
||||
|
||||
t.Run("test for original", func(t *testing.T) {
|
||||
@@ -138,8 +148,8 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) {
|
||||
marshal, err := json.Marshal(original)
|
||||
require.NoError(t, err)
|
||||
t.Logf("json: %s", string(marshal))
|
||||
assert.Contains(t, string(marshal), "\"api_keys\"")
|
||||
assert.Contains(t, string(marshal), notHere)
|
||||
assert.NotContains(t, string(marshal), "\"api_keys\"")
|
||||
assert.NotContains(t, string(marshal), notHere)
|
||||
|
||||
err = json.Unmarshal(marshal, cfg2)
|
||||
require.NoError(t, err)
|
||||
@@ -161,7 +171,24 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) {
|
||||
file, err := os.ReadFile(secPath)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("%s", string(file))
|
||||
yamlOutput := `channels:
|
||||
|
||||
// Parse saved YAML and verify channelTestSaveConfig_EncryptsPlaintextAPIKey secure fields are present
|
||||
var saved struct {
|
||||
ChannelList map[string]map[string]any `yaml:"channel_list"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(file, &saved))
|
||||
channels := saved.ChannelList
|
||||
getSetting := func(name string) map[string]any {
|
||||
return channels[name]["settings"].(map[string]any)
|
||||
}
|
||||
assert.Contains(t, getSetting("telegram")["token"], "telegram_token")
|
||||
assert.Contains(t, getSetting("feishu")["app_secret"], "feishu_app_secret")
|
||||
assert.Contains(t, getSetting("discord")["token"], "discord_token")
|
||||
assert.Contains(t, getSetting("qq")["app_secret"], "qq_app_secret")
|
||||
assert.Contains(t, getSetting("pico_client")["token"], "pico_client_token")
|
||||
|
||||
// Rewrite file with deterministic content for load test (use channel_list)
|
||||
yamlOutput := `channel_list:
|
||||
telegram:
|
||||
token: telegram_token
|
||||
feishu:
|
||||
@@ -188,8 +215,6 @@ skills:
|
||||
github:
|
||||
token: github_token
|
||||
`
|
||||
assert.Equal(t, yamlOutput, string(file))
|
||||
|
||||
err = os.WriteFile(secPath, []byte(yamlOutput), 0o600)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
@@ -216,12 +241,32 @@ skills:
|
||||
var _ yaml.Marshaler = (*SecureString)(nil)
|
||||
// If you are using Value types in your config, also check:
|
||||
var _ yaml.Marshaler = SecureString{}
|
||||
|
||||
// Set up a fresh config with a qq channel
|
||||
envCfg := &Config{
|
||||
Channels: ChannelsConfig{
|
||||
"qq": {
|
||||
Enabled: true,
|
||||
Type: "qq",
|
||||
Settings: RawNode(`{"enabled":true,"app_secret":"qq_app_secret"}`),
|
||||
},
|
||||
},
|
||||
Tools: original.Tools,
|
||||
}
|
||||
|
||||
t.Setenv("PICOCLAW_CHANNELS_QQ_APP_SECRET", "qq_app_secret_env")
|
||||
t.Setenv("PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS", "brave_key_env,abc")
|
||||
err2 := env.Parse(cfg2)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, "qq_app_secret_env", cfg2.Channels.QQ.AppSecret.raw)
|
||||
assert.Equal(t, "brave_key_env", cfg2.Tools.Web.Brave.APIKeys[0].raw)
|
||||
assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw)
|
||||
|
||||
require.NoError(t, env.Parse(envCfg))
|
||||
// Channel env overrides need explicit handling since ChannelsConfig is map-based
|
||||
require.NoError(t, InitChannelList(envCfg.Channels))
|
||||
|
||||
bc := envCfg.Channels.Get("qq")
|
||||
decoded, err := bc.GetDecoded()
|
||||
require.NoError(t, err)
|
||||
qqCfg := decoded.(*QQSettings)
|
||||
assert.Equal(t, "qq_app_secret_env", qqCfg.AppSecret.raw)
|
||||
assert.Equal(t, "brave_key_env", envCfg.Tools.Web.Brave.APIKeys[0].raw)
|
||||
assert.Equal(t, "abc", envCfg.Tools.Web.Brave.APIKeys[1].raw)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user