Merge pull request #2481 from cytown/channel

refactor(config):  make config.Channel to multiple instance support
This commit is contained in:
daming大铭
2026-04-13 23:41:32 +08:00
committed by GitHub
185 changed files with 6390 additions and 4181 deletions
+80 -33
View File
@@ -327,8 +327,13 @@ import (
)
func init() {
channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewTelegramChannel(cfg, b)
channels.RegisterFactory(config.ChannelTelegram, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil { return nil, err }
c, ok := decoded.(*config.TelegramSettings)
if !ok { return nil, channels.ErrSendFailed }
return NewTelegramChannel(bc, c, b)
})
}
```
@@ -427,8 +432,13 @@ import (
)
func init() {
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewMatrixChannel(cfg, b)
channels.RegisterFactory(config.ChannelMatrix, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil { return nil, err }
c, ok := decoded.(*config.MatrixSettings)
if !ok { return nil, channels.ErrSendFailed }
return NewMatrixChannel(bc, c, b)
})
}
```
@@ -773,41 +783,59 @@ When the Agent finishes processing a message, Manager's `preSend` automatically:
### 3.5 Register Configuration and Gateway Integration
#### Add configuration in `pkg/config/config.go`
#### Add configuration entry
Channels now use a unified map-based configuration (`map[string]*config.Channel`).
Each channel entry stores common fields (`enabled`, `type`, `allow_from`, etc.) at
the top level, with channel-specific settings in the `settings` sub-key:
```json
{
"channels": {
"matrix": {
"enabled": true,
"type": "matrix",
"allow_from": ["@user:example.com"],
"settings": {
"home_server": "https://matrix.org",
"user_id": "@bot:example.com",
"access_token": "enc://..."
}
}
}
}
```
Secure fields (tokens, passwords, API keys) go into `.security.yml`:
```yaml
channels:
matrix:
access_token: "your-matrix-access-token"
```
Channel types must be registered in `channelSettingsFactory` in
`pkg/config/config_channel.go`:
```go
type ChannelsConfig struct {
var channelSettingsFactory = map[string]any{
// ... existing channels
Matrix MatrixChannelConfig `json:"matrix"`
}
type MatrixChannelConfig struct {
Enabled bool `json:"enabled"`
HomeServer string `json:"home_server"`
Token string `json:"token"`
AllowFrom []string `json:"allow_from"`
GroupTrigger GroupTriggerConfig `json:"group_trigger"`
Placeholder PlaceholderConfig `json:"placeholder"`
ReasoningChannelID string `json:"reasoning_channel_id"`
ChannelMatrix: (MatrixSettings{}),
}
```
#### Add entry in Manager.initChannels()
#### No Manager changes needed
```go
// In the initChannels() method of pkg/channels/manager.go
if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" {
m.initChannel("matrix", "Matrix")
}
```
The Manager uses `InitChannelList()` to validate types and decode settings,
then looks up factories by `bc.Type`. No per-channel entry needed in Manager —
just register the factory and the config entry.
> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config:
> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native),
> register both types in `channelSettingsFactory` and branch on config:
> ```go
> if cfg.UseNative {
> m.initChannel("whatsapp_native", "WhatsApp Native")
> } else {
> m.initChannel("whatsapp", "WhatsApp")
> }
> // In config_channel.go:
> ChannelWhatsApp: (WhatsAppSettings{}),
> ChannelWhatsAppNative: (WhatsAppSettings{}),
> ```
#### Add blank import in Gateway
@@ -947,10 +975,29 @@ channels.WithReasoningChannelID(id) // Set reasoning chain routing target
**File**: `pkg/channels/registry.go`
```go
type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error)
func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init()
func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager
func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init()
func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager
func GetRegisteredFactoryNames() []string // Returns all registered factory names
```
For convenience, `RegisterSafeFactory[S any]` provides automatic type-safe settings decoding:
```go
// Instead of manual GetDecoded() + type assertion:
channels.RegisterFactory(config.ChannelTelegram,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil { return nil, err }
c, ok := decoded.(*config.TelegramSettings)
if !ok { return nil, ErrSendFailed }
return NewTelegramChannel(bc, c, b)
})
// You can use RegisterSafeFactory (same safety, less boilerplate):
channels.RegisterSafeFactory(config.ChannelTelegram, NewTelegramChannel)
```
The factory registry is protected by `sync.RWMutex` and registrations occur during `init()` phase (completed at process startup). Manager looks up factories by name in `initChannel()` and calls them.
+79 -33
View File
@@ -327,8 +327,13 @@ import (
)
func init() {
channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewTelegramChannel(cfg, b)
channels.RegisterFactory(config.ChannelTelegram, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil { return nil, err }
c, ok := decoded.(*config.TelegramSettings)
if !ok { return nil, channels.ErrSendFailed }
return NewTelegramChannel(bc, c, b)
})
}
```
@@ -427,8 +432,13 @@ import (
)
func init() {
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewMatrixChannel(cfg, b)
channels.RegisterFactory(config.ChannelMatrix, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil { return nil, err }
c, ok := decoded.(*config.MatrixSettings)
if !ok { return nil, channels.ErrSendFailed }
return NewMatrixChannel(bc, c, b)
})
}
```
@@ -772,41 +782,58 @@ if c.owner != nil && c.placeholderRecorder != nil {
### 3.5 注册配置和 Gateway 接入
#### 在 `pkg/config/config.go` 中添加配置
#### 添加配置入口
Channels 现在使用统一的 map 类型配置(`map[string]*config.Channel`)。
每个 channel 条目将通用字段(`enabled``type``allow_from` 等)放在顶层,
channel 特定的设置放在 `settings` 子键中:
```json
{
"channels": {
"matrix": {
"enabled": true,
"type": "matrix",
"allow_from": ["@user:example.com"],
"settings": {
"home_server": "https://matrix.org",
"user_id": "@bot:example.com",
"access_token": "enc://..."
}
}
}
}
```
安全字段(token、密码、API 密钥)放入 `.security.yml`
```yaml
channels:
matrix:
access_token: "your-matrix-access-token"
```
Channel 类型必须在 `pkg/config/config_channel.go``channelSettingsFactory` 中注册:
```go
type ChannelsConfig struct {
var channelSettingsFactory = map[string]any{
// ... 现有 channels
Matrix MatrixChannelConfig `json:"matrix"`
}
type MatrixChannelConfig struct {
Enabled bool `json:"enabled"`
HomeServer string `json:"home_server"`
Token string `json:"token"`
AllowFrom []string `json:"allow_from"`
GroupTrigger GroupTriggerConfig `json:"group_trigger"`
Placeholder PlaceholderConfig `json:"placeholder"`
ReasoningChannelID string `json:"reasoning_channel_id"`
ChannelMatrix: (MatrixSettings{}),
}
```
#### Manager.initChannels() 中添加入口
#### 无需修改 Manager
```go
// pkg/channels/manager.go 的 initChannels() 方法中
if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" {
m.initChannel("matrix", "Matrix")
}
```
Manager 使用 `InitChannelList()` 来验证类型和解码设置,
然后通过 `bc.Type` 查找工厂。不需要在 Manager 中添加每个 channel 的条目——
只需注册工厂和配置条目即可。
> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),需要在 initChannels 中根据配置分支:
> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),
> 在 `channelSettingsFactory` 中注册两种类型,并根据配置分支:
> ```go
> if cfg.UseNative {
> m.initChannel("whatsapp_native", "WhatsApp Native")
> } else {
> m.initChannel("whatsapp", "WhatsApp")
> }
> // 在 config_channel.go 中:
> ChannelWhatsApp: (WhatsAppSettings{}),
> ChannelWhatsAppNative: (WhatsAppSettings{}),
> ```
#### 在 Gateway 中添加 blank import
@@ -946,10 +973,29 @@ channels.WithReasoningChannelID(id) // 设置思维链路由目标 channe
**文件**`pkg/channels/registry.go`
```go
type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error)
func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用
func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用
func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用
func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用
func GetRegisteredFactoryNames() []string // 返回所有已注册的工厂名称
```
为方便使用,`RegisterSafeFactory[S any]` 提供自动类型安全的设置解码:
```go
// 不使用 RegisterSafeFactory(手动 GetDecoded() + 类型断言):
channels.RegisterFactory(config.ChannelTelegram,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil { return nil, err }
c, ok := decoded.(*config.TelegramSettings)
if !ok { return nil, ErrSendFailed }
return NewTelegramChannel(bc, c, b)
})
// 使用 RegisterSafeFactory(同等安全,减少样板代码):
channels.RegisterSafeFactory(config.ChannelTelegram, NewTelegramChannel)
```
工厂注册表使用 `sync.RWMutex` 保护,在 `init()` 阶段注册(进程启动时完成)。Manager 在 `initChannel()` 中通过名字查找工厂并调用它。
+6
View File
@@ -187,6 +187,12 @@ func (c *BaseChannel) Name() string {
return c.name
}
// SetName updates the channel name. Used by the manager after channel creation
// to ensure the name matches the config key (which may differ from the type).
func (c *BaseChannel) SetName(name string) {
c.name = name
}
func (c *BaseChannel) ReasoningChannelID() string {
return c.reasoningChannelID
}
+9 -5
View File
@@ -25,7 +25,7 @@ import (
// It uses WebSocket for receiving messages via stream mode and API for sending
type DingTalkChannel struct {
*channels.BaseChannel
config config.DingTalkConfig
config *config.DingTalkSettings
clientID string
clientSecret string
streamClient *client.StreamClient
@@ -36,7 +36,11 @@ type DingTalkChannel struct {
}
// NewDingTalkChannel creates a new DingTalk channel instance
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
func NewDingTalkChannel(
bc *config.Channel,
cfg *config.DingTalkSettings,
messageBus *bus.MessageBus,
) (*DingTalkChannel, error) {
if cfg.ClientID == "" || cfg.ClientSecret.String() == "" {
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
@@ -44,10 +48,10 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
// Set the logger for the Stream SDK
dinglog.SetLogger(logger.NewLogger("dingtalk"))
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &DingTalkChannel{
+15 -5
View File
@@ -11,7 +11,11 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
)
func newTestDingTalkChannel(t *testing.T, cfg config.DingTalkConfig) (*DingTalkChannel, *bus.MessageBus) {
func newTestDingTalkChannel(
t *testing.T,
cfg config.DingTalkSettings,
bc *config.Channel,
) (*DingTalkChannel, *bus.MessageBus) {
t.Helper()
if cfg.ClientID == "" {
@@ -22,7 +26,10 @@ func newTestDingTalkChannel(t *testing.T, cfg config.DingTalkConfig) (*DingTalkC
}
msgBus := bus.NewMessageBus()
ch, err := NewDingTalkChannel(cfg, msgBus)
if bc == nil {
bc = &config.Channel{Type: config.ChannelDingTalk, Enabled: true}
}
ch, err := NewDingTalkChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("new channel: %v", err)
}
@@ -41,9 +48,12 @@ func mustReceiveInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage
}
func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention(t *testing.T) {
ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{
bc := &config.Channel{
Type: config.ChannelDingTalk,
Enabled: true,
GroupTrigger: config.GroupTriggerConfig{MentionOnly: true},
})
}
ch, msgBus := newTestDingTalkChannel(t, config.DingTalkSettings{}, bc)
_, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{
Text: chatbot.BotCallbackDataTextModel{Content: " @bot /help "},
@@ -74,7 +84,7 @@ func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention
}
func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *testing.T) {
ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{})
ch, msgBus := newTestDingTalkChannel(t, config.DingTalkSettings{}, nil)
_, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{
Text: chatbot.BotCallbackDataTextModel{Content: "ping"},
+22 -3
View File
@@ -7,7 +7,26 @@ import (
)
func init() {
channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewDingTalkChannel(cfg.Channels.DingTalk, b)
})
channels.RegisterFactory(
config.ChannelDingTalk,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.DingTalkSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewDingTalkChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelDingTalk {
ch.SetName(channelName)
}
return ch, nil
},
)
}
+13 -7
View File
@@ -38,8 +38,9 @@ var (
type DiscordChannel struct {
*channels.BaseChannel
bc *config.Channel
session *discordgo.Session
config config.DiscordConfig
config *config.DiscordSettings
ctx context.Context
cancel context.CancelFunc
typingMu sync.Mutex
@@ -56,7 +57,11 @@ type DiscordChannel struct {
ttsPlayID uint64
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
func NewDiscordChannel(
bc *config.Channel,
cfg *config.DiscordSettings,
bus *bus.MessageBus,
) (*DiscordChannel, error) {
discordgo.Logger = logger.NewLogger("discord").
WithLevels(map[int]logger.LogLevel{
discordgo.LogError: logger.ERROR,
@@ -73,14 +78,15 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
if err := applyDiscordProxy(session, cfg.Proxy); err != nil {
return nil, err
}
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
base := channels.NewBaseChannel("discord", cfg, bus, bc.AllowFrom,
channels.WithMaxMessageLength(2000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &DiscordChannel{
BaseChannel: base,
bc: bc,
session: session,
config: cfg,
ctx: context.Background(),
@@ -297,11 +303,11 @@ func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, message
// It sends a placeholder message that will later be edited to the actual
// response via EditMessage (channels.MessageEditor).
func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
if !c.config.Placeholder.Enabled {
if !c.bc.Placeholder.Enabled {
return "", nil
}
text := c.config.Placeholder.GetRandomText()
text := c.bc.Placeholder.GetRandomText()
msg, err := c.session.ChannelMessageSend(chatID, text)
if err != nil {
+19 -7
View File
@@ -8,11 +8,23 @@ import (
)
func init() {
channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
ch, err := NewDiscordChannel(cfg.Channels.Discord, b)
if err == nil {
ch.tts = tts.DetectTTS(cfg)
}
return ch, err
})
channels.RegisterFactory(
config.ChannelDiscord,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.DiscordSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewDiscordChannel(bc, c, b)
if err == nil {
ch.tts = tts.DetectTTS(cfg)
}
return ch, err
},
)
}
+1 -1
View File
@@ -19,7 +19,7 @@ type FeishuChannel struct {
var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures")
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
func NewFeishuChannel(bc *config.Channel, cfg config.FeishuSettings, bus *bus.MessageBus) (*FeishuChannel, error) {
return nil, errors.New(
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
)
+9 -7
View File
@@ -38,7 +38,8 @@ const errCodeTenantTokenInvalid = 99991663
type FeishuChannel struct {
*channels.BaseChannel
config config.FeishuConfig
bc *config.Channel
config *config.FeishuSettings
client *lark.Client
wsClient *larkws.Client
tokenCache *tokenCache // custom cache that supports invalidation
@@ -55,10 +56,10 @@ type cachedMessage struct {
expiry time.Time
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, bc.AllowFrom,
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
tc := newTokenCache()
@@ -68,6 +69,7 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
}
ch := &FeishuChannel{
BaseChannel: base,
bc: bc,
config: cfg,
tokenCache: tc,
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
@@ -211,14 +213,14 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont
// SendPlaceholder implements channels.PlaceholderCapable.
// Sends an interactive card with placeholder text and returns its message ID.
func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
if !c.config.Placeholder.Enabled {
if !c.bc.Placeholder.Enabled {
logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{
"chat_id": chatID,
})
return "", nil
}
text := c.config.Placeholder.GetRandomText()
text := c.bc.Placeholder.GetRandomText()
cardContent, err := buildMarkdownCard(text)
if err != nil {
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewFeishuChannel(cfg.Channels.Feishu, b)
})
channels.RegisterFactory(
config.ChannelFeishu,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.FeishuSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewFeishuChannel(bc, c, b)
},
)
}
+25 -6
View File
@@ -7,10 +7,29 @@ import (
)
func init() {
channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
if !cfg.Channels.IRC.Enabled {
return nil, nil
}
return NewIRCChannel(cfg.Channels.IRC, b)
})
channels.RegisterFactory(
config.ChannelIRC,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
if bc == nil || !bc.Enabled {
return nil, nil
}
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.IRCSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewIRCChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelIRC {
ch.SetName(channelName)
}
return ch, nil
},
)
}
+8 -6
View File
@@ -18,14 +18,15 @@ import (
// IRCChannel implements the Channel interface for IRC servers.
type IRCChannel struct {
*channels.BaseChannel
config config.IRCConfig
bc *config.Channel
config *config.IRCSettings
conn *ircevent.Connection
ctx context.Context
cancel context.CancelFunc
}
// NewIRCChannel creates a new IRC channel.
func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
func NewIRCChannel(bc *config.Channel, cfg *config.IRCSettings, messageBus *bus.MessageBus) (*IRCChannel, error) {
if cfg.Server == "" {
return nil, fmt.Errorf("irc server is required")
}
@@ -33,14 +34,15 @@ func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChanne
return nil, fmt.Errorf("irc nick is required")
}
base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
base := channels.NewBaseChannel("irc", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(400),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &IRCChannel{
BaseChannel: base,
bc: bc,
config: cfg,
}, nil
}
@@ -166,7 +168,7 @@ func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]strin
func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
noop := func() {}
if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
if !c.bc.Typing.Enabled || !c.IsRunning() || c.conn == nil {
return noop, nil
}
+9 -6
View File
@@ -11,28 +11,31 @@ func TestNewIRCChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing server", func(t *testing.T) {
cfg := config.IRCConfig{Nick: "bot"}
_, err := NewIRCChannel(cfg, msgBus)
bc := &config.Channel{Type: config.ChannelIRC, Enabled: true}
cfg := &config.IRCSettings{Nick: "bot"}
_, err := NewIRCChannel(bc, cfg, msgBus)
if err == nil {
t.Error("expected error for missing server, got nil")
}
})
t.Run("missing nick", func(t *testing.T) {
cfg := config.IRCConfig{Server: "irc.example.com:6667"}
_, err := NewIRCChannel(cfg, msgBus)
bc := &config.Channel{Type: config.ChannelIRC, Enabled: true}
cfg := &config.IRCSettings{Server: "irc.example.com:6667"}
_, err := NewIRCChannel(bc, cfg, msgBus)
if err == nil {
t.Error("expected error for missing nick, got nil")
}
})
t.Run("valid config", func(t *testing.T) {
cfg := config.IRCConfig{
bc := &config.Channel{Type: config.ChannelIRC, Enabled: true}
cfg := &config.IRCSettings{
Server: "irc.example.com:6667",
Nick: "testbot",
Channels: []string{"#test"},
}
ch, err := NewIRCChannel(cfg, msgBus)
ch, err := NewIRCChannel(bc, cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewLINEChannel(cfg.Channels.LINE, b)
})
channels.RegisterFactory(
config.ChannelLINE,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.LINESettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewLINEChannel(bc, c, b)
},
)
}
+9 -5
View File
@@ -48,7 +48,7 @@ type replyTokenEntry struct {
// and REST API for sending messages.
type LINEChannel struct {
*channels.BaseChannel
config config.LINEConfig
config *config.LINESettings
infoClient *http.Client // for bot info lookups (short timeout)
apiClient *http.Client // for messaging API calls
botUserID string // Bot's user ID
@@ -61,15 +61,19 @@ type LINEChannel struct {
}
// NewLINEChannel creates a new LINE channel instance.
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
func NewLINEChannel(
bc *config.Channel,
cfg *config.LINESettings,
messageBus *bus.MessageBus,
) (*LINEChannel, error) {
if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" {
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
}
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &LINEChannel{
+5 -1
View File
@@ -6,6 +6,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestWebhookRejectsOversizedBody(t *testing.T) {
@@ -66,7 +68,9 @@ func TestWebhookRejectsNonPostMethod(t *testing.T) {
}
func TestWebhookRejectsInvalidSignature(t *testing.T) {
ch := &LINEChannel{}
ch := &LINEChannel{
config: &config.LINESettings{},
}
body := `{"events":[]}`
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewMaixCamChannel(cfg.Channels.MaixCam, b)
})
channels.RegisterFactory(
config.ChannelMaixCam,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.MaixCamSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewMaixCamChannel(bc, c, b)
},
)
}
+8 -4
View File
@@ -17,7 +17,7 @@ import (
type MaixCamChannel struct {
*channels.BaseChannel
config config.MaixCamConfig
config *config.MaixCamSettings
listener net.Listener
ctx context.Context
cancel context.CancelFunc
@@ -32,13 +32,17 @@ type MaixCamMessage struct {
Data map[string]any `json:"data"`
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
func NewMaixCamChannel(
bc *config.Channel,
cfg *config.MaixCamSettings,
bus *bus.MessageBus,
) (*MaixCamChannel, error) {
base := channels.NewBaseChannel(
"maixcam",
cfg,
bus,
cfg.AllowFrom,
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
bc.AllowFrom,
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &MaixCamChannel{
+112 -98
View File
@@ -311,22 +311,27 @@ func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) err
return nil
}
// initChannel is a helper that looks up a factory by name and creates the channel.
func (m *Manager) initChannel(name, displayName string) {
f, ok := getFactory(name)
// initChannel is a helper that looks up a factory by type name and creates the channel.
// typeName is the channel type used for factory lookup (e.g., "telegram").
// channelName is the config map key used as the channel's runtime name (e.g., "my_telegram").
func (m *Manager) initChannel(typeName, channelName string) {
f, ok := getFactory(typeName)
if !ok {
logger.WarnCF("channels", "Factory not registered", map[string]any{
"channel": displayName,
"channel": channelName,
"type": typeName,
})
return
}
logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{
"channel": displayName,
"channel": channelName,
"type": typeName,
})
ch, err := f(m.config, m.bus)
ch, err := f(channelName, typeName, m.config, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{
"channel": displayName,
"channel": channelName,
"type": typeName,
"error": err.Error(),
})
} else {
@@ -344,103 +349,100 @@ func (m *Manager) initChannel(name, displayName string) {
if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok {
setter.SetOwner(ch)
}
m.channels[name] = ch
m.channels[channelName] = ch
logger.InfoCF("channels", "Channel enabled successfully", map[string]any{
"channel": displayName,
"channel": channelName,
"type": typeName,
})
}
}
func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channel, bool) {
bc, ok := m.config.Channels[channelName]
if !ok || bc == nil {
return nil, false
}
if !bc.Enabled {
return bc, false
}
// Use Type to determine the config struct for validation.
// The map key (channelName) is the config key, which may differ from the type.
channelType := bc.Type
if channelType == "" {
channelType = channelName
}
// Settings have already been decoded by InitChannelList, so we just need to
// type-assert and check the relevant fields.
decoded, err := bc.GetDecoded()
if err != nil {
return bc, false
}
//nolint:revive
switch settings := decoded.(type) {
case *config.WhatsAppSettings:
if channelType == config.ChannelWhatsApp {
return bc, settings.BridgeURL != ""
}
return bc, channelType == config.ChannelWhatsAppNative && settings.UseNative
case *config.MatrixSettings:
return bc, settings.Homeserver != "" && settings.UserID != "" && settings.AccessToken.String() != ""
case *config.WeComSettings:
return bc, settings.BotID != "" && settings.Secret.String() != ""
case *config.PicoClientSettings:
return bc, settings.URL != ""
case *config.DingTalkSettings:
return bc, settings.ClientID != ""
case *config.SlackSettings:
return bc, settings.BotToken.String() != ""
case *config.WeixinSettings:
return bc, settings.Token.String() != ""
case *config.PicoSettings:
return bc, settings.Token.String() != ""
case *config.IRCSettings:
return bc, settings.Server != ""
case *config.LINESettings:
return bc, settings.ChannelAccessToken.String() != ""
case *config.OneBotSettings:
return bc, settings.WSUrl != ""
case *config.QQSettings:
return bc, settings.AppSecret.String() != ""
case *config.TelegramSettings:
return bc, settings.Token.String() != ""
case *config.FeishuSettings:
return bc, settings.AppSecret.String() != ""
case *config.MaixCamSettings:
return bc, true
case *config.TeamsWebhookSettings:
return bc, true
case *config.DiscordSettings:
return bc, settings.Token.String() != ""
case *config.VKSettings:
return bc, settings.GroupID != 0 && settings.Token.String() != ""
}
return bc, bc.Enabled
}
// initChannels initializes all enabled channels based on the configuration.
// It iterates config entries and uses bc.Type to look up the appropriate factory.
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
logger.InfoC("channels", "Initializing channel manager")
if channels.Telegram.Enabled && channels.Telegram.Token.String() != "" {
m.initChannel("telegram", "Telegram")
}
if channels.WhatsApp.Enabled {
waCfg := channels.WhatsApp
if waCfg.UseNative {
m.initChannel("whatsapp_native", "WhatsApp Native")
} else if waCfg.BridgeURL != "" {
m.initChannel("whatsapp", "WhatsApp")
for name, bc := range *channels {
if !bc.Enabled {
continue
}
}
if channels.Feishu.Enabled {
m.initChannel("feishu", "Feishu")
}
if channels.Discord.Enabled && channels.Discord.Token.String() != "" {
m.initChannel("discord", "Discord")
}
if channels.MaixCam.Enabled {
m.initChannel("maixcam", "MaixCam")
}
if channels.QQ.Enabled {
m.initChannel("qq", "QQ")
}
if channels.DingTalk.Enabled && channels.DingTalk.ClientID != "" {
m.initChannel("dingtalk", "DingTalk")
}
if channels.Slack.Enabled && channels.Slack.BotToken.String() != "" {
m.initChannel("slack", "Slack")
}
if channels.Matrix.Enabled &&
m.config.Channels.Matrix.Homeserver != "" &&
m.config.Channels.Matrix.UserID != "" &&
m.config.Channels.Matrix.AccessToken.String() != "" {
m.initChannel("matrix", "Matrix")
}
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken.String() != "" {
m.initChannel("line", "LINE")
}
if channels.OneBot.Enabled && channels.OneBot.WSUrl != "" {
m.initChannel("onebot", "OneBot")
}
if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret.String() != "" {
m.initChannel("wecom", "WeCom")
}
if channels.Weixin.Enabled && channels.Weixin.Token.String() != "" {
m.initChannel("weixin", "Weixin")
}
if channels.Pico.Enabled && channels.Pico.Token.String() != "" {
m.initChannel("pico", "Pico")
}
if channels.PicoClient.Enabled && channels.PicoClient.URL != "" {
m.initChannel("pico_client", "Pico Client")
}
if channels.IRC.Enabled && channels.IRC.Server != "" {
m.initChannel("irc", "IRC")
}
if channels.VK.Enabled && channels.VK.Token.String() != "" && channels.VK.GroupID != 0 {
m.initChannel("vk", "VK")
}
if channels.TeamsWebhook.Enabled && len(channels.TeamsWebhook.Webhooks) > 0 {
hasValidTarget := false
for _, target := range channels.TeamsWebhook.Webhooks {
if target.WebhookURL.String() != "" {
hasValidTarget = true
break
}
_, ready := m.getChannelConfigAndEnabled(name)
if !ready {
continue
}
if hasValidTarget {
m.initChannel("teams_webhook", "Teams Webhook")
typeName := bc.Type
if typeName == "" {
typeName = name
}
m.initChannel(typeName, name)
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
@@ -548,7 +550,13 @@ func (m *Manager) StartAll(ctx context.Context) error {
continue
}
// Lazily create worker only after channel starts successfully
w := newChannelWorker(name, channel)
channelType := name
if m.config != nil {
if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" {
channelType = bc.Type
}
}
w := newChannelWorker(name, channel, channelType)
m.workers[name] = w
go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w)
@@ -678,10 +686,10 @@ func (m *Manager) StopAll(ctx context.Context) error {
}
// newChannelWorker creates a channelWorker with a rate limiter configured
// for the given channel name.
func newChannelWorker(name string, ch Channel) *channelWorker {
// for the given channel type. channelType is used for rate limit lookup.
func newChannelWorker(name string, ch Channel, channelType string) *channelWorker {
rateVal := float64(defaultRateLimit)
if r, ok := channelRateConfig[name]; ok {
if r, ok := channelRateConfig[channelType]; ok {
rateVal = r
}
burst := int(math.Max(1, math.Ceil(rateVal/2)))
@@ -1137,7 +1145,13 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
continue
}
// Lazily create worker only after channel starts successfully
w := newChannelWorker(name, channel)
channelType := name
if m.config != nil {
if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" {
channelType = bc.Type
}
}
w := newChannelWorker(name, channel, channelType)
m.workers[name] = w
go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w)
+36 -109
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
func toChannelHashes(cfg *config.Config) map[string]string {
@@ -21,7 +20,7 @@ func toChannelHashes(cfg *config.Config) map[string]string {
if !value["enabled"].(bool) {
continue
}
hiddenValues(key, value, ch)
hiddenValues(key, value, ch.Get(key))
valueBytes, _ := json.Marshal(value)
hash := md5.Sum(valueBytes)
result[key] = hex.EncodeToString(hash[:])
@@ -30,42 +29,51 @@ func toChannelHashes(cfg *config.Config) map[string]string {
return result
}
func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
func hiddenValues(key string, value map[string]any, ch *config.Channel) {
v, err := ch.GetDecoded()
if err != nil {
return
}
switch key {
case "pico":
value["token"] = ch.Pico.Token.String()
value["token"] = v.(*config.PicoSettings).Token.String()
case "telegram":
value["token"] = ch.Telegram.Token.String()
value["token"] = v.(*config.TelegramSettings).Token.String()
case "discord":
value["token"] = ch.Discord.Token.String()
value["token"] = v.(*config.DiscordSettings).Token.String()
case "slack":
value["bot_token"] = ch.Slack.BotToken.String()
value["app_token"] = ch.Slack.AppToken.String()
value["bot_token"] = v.(*config.SlackSettings).BotToken.String()
value["app_token"] = v.(*config.SlackSettings).AppToken.String()
case "matrix":
value["token"] = ch.Matrix.AccessToken.String()
value["token"] = v.(*config.MatrixSettings).AccessToken.String()
case "onebot":
value["token"] = ch.OneBot.AccessToken.String()
value["token"] = v.(*config.OneBotSettings).AccessToken.String()
case "line":
value["token"] = ch.LINE.ChannelAccessToken.String()
value["secret"] = ch.LINE.ChannelSecret.String()
value["token"] = v.(*config.LINESettings).ChannelAccessToken.String()
value["secret"] = v.(*config.LINESettings).ChannelSecret.String()
case "wecom":
value["secret"] = ch.WeCom.Secret.String()
value["secret"] = v.(*config.WeComSettings).Secret.String()
case "dingtalk":
value["secret"] = ch.DingTalk.ClientSecret.String()
value["secret"] = v.(*config.DingTalkSettings).ClientSecret.String()
case "qq":
value["secret"] = ch.QQ.AppSecret.String()
value["secret"] = v.(*config.QQSettings).AppSecret.String()
case "irc":
value["password"] = ch.IRC.Password.String()
value["serv_password"] = ch.IRC.NickServPassword.String()
value["sasl_password"] = ch.IRC.SASLPassword.String()
value["password"] = v.(*config.IRCSettings).Password.String()
value["serv_password"] = v.(*config.IRCSettings).NickServPassword.String()
value["sasl_password"] = v.(*config.IRCSettings).SASLPassword.String()
case "feishu":
value["app_secret"] = ch.Feishu.AppSecret.String()
value["encrypt_key"] = ch.Feishu.EncryptKey.String()
value["verification_token"] = ch.Feishu.VerificationToken.String()
value["app_secret"] = v.(*config.FeishuSettings).AppSecret.String()
value["encrypt_key"] = v.(*config.FeishuSettings).EncryptKey.String()
value["verification_token"] = v.(*config.FeishuSettings).VerificationToken.String()
case "teams_webhook":
// Expose webhook URLs for hash computation (they contain secrets)
vv := value["webhooks"]
webhooks := make(map[string]string)
for name, target := range ch.TeamsWebhook.Webhooks {
if vv != nil {
webhooks = vv.(map[string]string)
}
ts := v.(*config.TeamsWebhookSettings)
for name, target := range ts.Webhooks {
webhooks[name] = target.WebhookURL.String()
}
value["webhooks"] = webhooks
@@ -92,94 +100,13 @@ func compareChannels(old, news map[string]string) (added, removed []string) {
}
func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, error) {
result := &config.ChannelsConfig{}
ch := cfg.Channels
// should not be error
marshal, _ := json.Marshal(ch)
var channelConfig map[string]map[string]any
_ = json.Unmarshal(marshal, &channelConfig)
temp := make(map[string]map[string]any, 0)
for key, value := range channelConfig {
found := false
for _, s := range list {
if key == s {
found = true
break
}
}
if !found || !value["enabled"].(bool) {
result := make(config.ChannelsConfig)
for _, name := range list {
bc, ok := cfg.Channels[name]
if !ok || !bc.Enabled {
continue
}
temp[key] = value
}
marshal, err := json.Marshal(temp)
if err != nil {
logger.Errorf("marshal error: %v", err)
return nil, err
}
err = json.Unmarshal(marshal, result)
if err != nil {
logger.Errorf("unmarshal error: %v", err)
return nil, err
}
updateKeys(result, &ch)
return result, nil
}
func updateKeys(newcfg, old *config.ChannelsConfig) {
if newcfg.Pico.Enabled {
newcfg.Pico.Token = old.Pico.Token
}
if newcfg.Telegram.Enabled {
newcfg.Telegram.Token = old.Telegram.Token
}
if newcfg.Discord.Enabled {
newcfg.Discord.Token = old.Discord.Token
}
if newcfg.Slack.Enabled {
newcfg.Slack.BotToken = old.Slack.BotToken
newcfg.Slack.AppToken = old.Slack.AppToken
}
if newcfg.Matrix.Enabled {
newcfg.Matrix.AccessToken = old.Matrix.AccessToken
}
if newcfg.OneBot.Enabled {
newcfg.OneBot.AccessToken = old.OneBot.AccessToken
}
if newcfg.LINE.Enabled {
newcfg.LINE.ChannelAccessToken = old.LINE.ChannelAccessToken
newcfg.LINE.ChannelSecret = old.LINE.ChannelSecret
}
if newcfg.WeCom.Enabled {
newcfg.WeCom.Secret = old.WeCom.Secret
}
if newcfg.DingTalk.Enabled {
newcfg.DingTalk.ClientSecret = old.DingTalk.ClientSecret
}
if newcfg.QQ.Enabled {
newcfg.QQ.AppSecret = old.QQ.AppSecret
}
if newcfg.IRC.Enabled {
newcfg.IRC.Password = old.IRC.Password
newcfg.IRC.NickServPassword = old.IRC.NickServPassword
newcfg.IRC.SASLPassword = old.IRC.SASLPassword
}
if newcfg.Feishu.Enabled {
newcfg.Feishu.AppSecret = old.Feishu.AppSecret
newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey
newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken
}
if newcfg.TeamsWebhook.Enabled {
// Copy SecureString webhook URLs from old config
for name, oldTarget := range old.TeamsWebhook.Webhooks {
if newTarget, ok := newcfg.TeamsWebhook.Webhooks[name]; ok {
newTarget.WebhookURL = oldTarget.WebhookURL
newcfg.TeamsWebhook.Webhooks[name] = newTarget
}
}
result[name] = bc
}
return &result, nil
}
+111 -9
View File
@@ -1,6 +1,7 @@
package channels
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
@@ -15,37 +16,138 @@ func TestToChannelHashes(t *testing.T) {
results := toChannelHashes(cfg)
assert.Equal(t, 0, len(results))
logger.Debugf("results: %v", results)
// Add dingtalk channel via map
cfg2 := config.DefaultConfig()
cfg2.Channels.DingTalk.Enabled = true
cfg2.Channels["dingtalk"] = &config.Channel{
Enabled: true,
Type: config.ChannelDingTalk,
Settings: config.RawNode(`{"enabled":true}`),
}
results2 := toChannelHashes(cfg2)
assert.Equal(t, 1, len(results2))
logger.Debugf("results2: %v", results2)
added, removed := compareChannels(results, results2)
assert.EqualValues(t, []string{"dingtalk"}, added)
assert.EqualValues(t, []string(nil), removed)
// Add telegram channel
cfg3 := config.DefaultConfig()
cfg3.Channels.Telegram.Enabled = true
cfg3.Channels["telegram"] = &config.Channel{
Enabled: true,
Type: config.ChannelTelegram,
Settings: config.RawNode(`{"enabled":true,"token":"test-token"}`),
}
results3 := toChannelHashes(cfg3)
assert.Equal(t, 1, len(results3))
logger.Debugf("results3: %v", results3)
added, removed = compareChannels(results2, results3)
assert.EqualValues(t, []string{"dingtalk"}, removed)
assert.EqualValues(t, []string{"telegram"}, added)
cfg3.Channels.Telegram.SetToken("114314")
// Modify telegram channel — hash should change
cfg3.Channels["telegram"] = &config.Channel{
Enabled: true,
Type: config.ChannelTelegram,
Settings: config.RawNode(`{"enabled":true,"token":"114314"}`),
}
results4 := toChannelHashes(cfg3)
assert.Equal(t, 1, len(results4))
logger.Debugf("results4: %v", results4)
added, removed = compareChannels(results3, results4)
assert.EqualValues(t, []string{"telegram"}, removed)
assert.EqualValues(t, []string{"telegram"}, added)
// toChannelConfig with telegram
cc, err := toChannelConfig(cfg3, added)
assert.NoError(t, err)
logger.Debugf("cc: %#v", cc.Telegram)
assert.Equal(t, "114314", cc.Telegram.Token.String())
assert.Equal(t, true, cc.Telegram.Enabled)
bc := cc.Get("telegram")
assert.NotNil(t, bc)
var tc config.TelegramSettings
bc.Decode(&tc)
assert.Equal(t, "114314", tc.Token.String())
assert.Equal(t, true, bc.Enabled)
// toChannelConfig with dingtalk (no telegram)
cc, err = toChannelConfig(cfg2, added)
assert.NoError(t, err)
logger.Debugf("cc: %#v", cc.Telegram)
assert.Equal(t, "", cc.Telegram.Token.String())
assert.Equal(t, false, cc.Telegram.Enabled)
bc = cc.Get("telegram")
assert.Nil(t, bc)
}
func TestToChannelHashes_SerializationStability(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{
Enabled: true,
Settings: config.RawNode(`{"enabled":true,"key":"value"}`),
}
h1 := toChannelHashes(cfg)
// Same config should produce same hash
cfg2 := config.DefaultConfig()
cfg2.Channels["test"] = &config.Channel{
Enabled: true,
Settings: config.RawNode(`{"enabled":true,"key":"value"}`),
}
h2 := toChannelHashes(cfg2)
assert.Equal(t, h1["test"], h2["test"])
}
func TestCompareChannels_NoChanges(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["a"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)}
cfg.Channels["b"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)}
h := toChannelHashes(cfg)
added, removed := compareChannels(h, h)
assert.EqualValues(t, []string(nil), added)
assert.EqualValues(t, []string(nil), removed)
}
func TestToChannelConfig_EmptyList(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)}
cc, err := toChannelConfig(cfg, []string{})
assert.NoError(t, err)
assert.Equal(t, 0, len(*cc))
}
func TestToChannelHashes_NonEnabledSkipped(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{Enabled: false, Settings: config.RawNode(`{"enabled":false}`)}
h := toChannelHashes(cfg)
assert.Equal(t, 0, len(h))
}
func TestToChannelHashes_InvalidJSON(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels["test"] = &config.Channel{
Enabled: true,
Settings: config.RawNode(`invalid-json`),
}
// Should not panic, just skip the invalid entry
h := toChannelHashes(cfg)
assert.Equal(t, 0, len(h))
}
func TestToChannelHashes_RealWorldChannel(t *testing.T) {
cfg := config.DefaultConfig()
// Simulate a telegram channel config
telegramSettings, _ := json.Marshal(map[string]any{
"enabled": true,
"token": "123456:ABC-DEF",
})
cfg.Channels["telegram"] = &config.Channel{
Enabled: true,
Type: config.ChannelTelegram,
Settings: config.RawNode(telegramSettings),
}
h := toChannelHashes(cfg)
assert.Equal(t, 1, len(h))
assert.Contains(t, h, "telegram")
}
+5 -5
View File
@@ -586,7 +586,7 @@ func TestWorkerRateLimiter(t *testing.T) {
func TestNewChannelWorker_DefaultRate(t *testing.T) {
ch := &mockChannel{}
w := newChannelWorker("unknown_channel", ch)
w := newChannelWorker("unknown_channel", ch, "unknown_channel")
if w.limiter == nil {
t.Fatal("expected limiter to be non-nil")
@@ -599,10 +599,10 @@ func TestNewChannelWorker_DefaultRate(t *testing.T) {
func TestNewChannelWorker_ConfiguredRate(t *testing.T) {
ch := &mockChannel{}
for name, expectedRate := range channelRateConfig {
w := newChannelWorker(name, ch)
for channelType, expectedRate := range channelRateConfig {
w := newChannelWorker(channelType, ch, channelType)
if w.limiter.Limit() != rate.Limit(expectedRate) {
t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit())
t.Fatalf("channel %s: expected rate %v, got %v", channelType, expectedRate, w.limiter.Limit())
}
}
}
@@ -1222,7 +1222,7 @@ func TestManager_PlaceholderConsumedByResponse(t *testing.T) {
return nil
},
}
worker := newChannelWorker("mock", mockCh)
worker := newChannelWorker("mock", mockCh, "mock")
mgr.channels["mock"] = mockCh
mgr.workers["mock"] = worker
+26 -8
View File
@@ -9,12 +9,30 @@ import (
)
func init() {
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
matrixCfg := cfg.Channels.Matrix
cryptoDatabasePath := matrixCfg.CryptoDatabasePath
if cryptoDatabasePath == "" {
cryptoDatabasePath = filepath.Join(cfg.WorkspacePath(), "matrix")
}
return NewMatrixChannel(matrixCfg, b, cryptoDatabasePath)
})
channels.RegisterFactory(
config.ChannelMatrix,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.MatrixSettings)
if !ok {
return nil, channels.ErrSendFailed
}
cryptoDatabasePath := c.CryptoDatabasePath
if cryptoDatabasePath == "" {
cryptoDatabasePath = filepath.Join(cfg.WorkspacePath(), "matrix")
}
ch, err := NewMatrixChannel(bc, c, b, cryptoDatabasePath)
if err != nil {
return nil, err
}
if channelName != config.ChannelMatrix {
ch.SetName(channelName)
}
return ch, nil
},
)
}
+12 -9
View File
@@ -174,9 +174,10 @@ func (s *typingSession) stop() {
// MatrixChannel implements the Channel interface for Matrix.
type MatrixChannel struct {
*channels.BaseChannel
bc *config.Channel
client *mautrix.Client
config config.MatrixConfig
config *config.MatrixSettings
syncer *mautrix.DefaultSyncer
ctx context.Context
@@ -194,7 +195,8 @@ type MatrixChannel struct {
}
func NewMatrixChannel(
cfg config.MatrixConfig,
bc *config.Channel,
cfg *config.MatrixSettings,
messageBus *bus.MessageBus,
cryptoDatabasePath string,
) (*MatrixChannel, error) {
@@ -228,14 +230,15 @@ func NewMatrixChannel(
"matrix",
cfg,
messageBus,
cfg.AllowFrom,
bc.AllowFrom,
channels.WithMaxMessageLength(65536),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &MatrixChannel{
BaseChannel: base,
bc: bc,
client: client,
config: cfg,
syncer: syncer,
@@ -570,7 +573,7 @@ func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (func(),
// SendPlaceholder implements channels.PlaceholderCapable.
func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
if !c.config.Placeholder.Enabled {
if !c.bc.Placeholder.Enabled {
return "", nil
}
@@ -579,7 +582,7 @@ func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (str
return "", fmt.Errorf("matrix room ID is empty")
}
text := c.config.Placeholder.GetRandomText()
text := c.bc.Placeholder.GetRandomText()
resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
MsgType: event.MsgNotice,
@@ -720,8 +723,8 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event
logger.DebugCF("matrix", "Ignoring group message by trigger rules", map[string]any{
"room_id": roomID,
"is_mentioned": isMentioned,
"mention_only": c.config.GroupTrigger.MentionOnly,
"prefixes": c.config.GroupTrigger.Prefixes,
"mention_only": c.bc.GroupTrigger.MentionOnly,
"prefixes": c.bc.GroupTrigger.Prefixes,
})
return
}
+3 -3
View File
@@ -437,9 +437,9 @@ func TestMarkdownToHTML(t *testing.T) {
}
func TestMessageContent(t *testing.T) {
richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}}
plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}}
defaultt := &MatrixChannel{config: config.MatrixConfig{}}
richtext := &MatrixChannel{config: &config.MatrixSettings{MessageFormat: "richtext"}}
plain := &MatrixChannel{config: &config.MatrixSettings{MessageFormat: "plain"}}
defaultt := &MatrixChannel{config: &config.MatrixSettings{}}
for _, c := range []*MatrixChannel{richtext, defaultt} {
mc := c.messageContent("**hi**")
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewOneBotChannel(cfg.Channels.OneBot, b)
})
channels.RegisterFactory(
config.ChannelOneBot,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.OneBotSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewOneBotChannel(bc, c, b)
},
)
}
+9 -5
View File
@@ -23,7 +23,7 @@ import (
type OneBotChannel struct {
*channels.BaseChannel
config config.OneBotConfig
config *config.OneBotSettings
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
@@ -96,10 +96,14 @@ type oneBotMessageSegment struct {
Data map[string]any `json:"data"`
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
func NewOneBotChannel(
bc *config.Channel,
cfg *config.OneBotSettings,
messageBus *bus.MessageBus,
) (*OneBotChannel, error) {
base := channels.NewBaseChannel("onebot", cfg, messageBus, bc.AllowFrom,
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
const dedupSize = 1024
+4 -3
View File
@@ -22,7 +22,7 @@ import (
// PicoClientChannel connects to a remote Pico Protocol WebSocket server.
type PicoClientChannel struct {
*channels.BaseChannel
config config.PicoClientConfig
config *config.PicoClientSettings
conn *picoConn
mu sync.Mutex
ctx context.Context
@@ -31,14 +31,15 @@ type PicoClientChannel struct {
// NewPicoClientChannel creates a new Pico Protocol client channel.
func NewPicoClientChannel(
cfg config.PicoClientConfig,
bc *config.Channel,
cfg *config.PicoClientSettings,
messageBus *bus.MessageBus,
) (*PicoClientChannel, error) {
if cfg.URL == "" {
return nil, fmt.Errorf("pico_client url is required")
}
base := channels.NewBaseChannel("pico_client", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("pico_client", cfg, messageBus, bc.AllowFrom)
return &PicoClientChannel{
BaseChannel: base,
+20 -10
View File
@@ -18,7 +18,8 @@ import (
)
func TestNewPicoClientChannel_MissingURL(t *testing.T) {
_, err := NewPicoClientChannel(config.PicoClientConfig{}, bus.NewMessageBus())
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
_, err := NewPicoClientChannel(bc, &config.PicoClientSettings{}, bus.NewMessageBus())
if err == nil {
t.Fatal("expected error for missing URL")
}
@@ -28,7 +29,8 @@ func TestNewPicoClientChannel_MissingURL(t *testing.T) {
}
func TestNewPicoClientChannel_OK(t *testing.T) {
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: "ws://localhost:9999/ws",
}, bus.NewMessageBus())
if err != nil {
@@ -40,7 +42,8 @@ func TestNewPicoClientChannel_OK(t *testing.T) {
}
func TestSend_NotRunning(t *testing.T) {
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: "ws://localhost:9999/ws",
}, bus.NewMessageBus())
if err != nil {
@@ -104,7 +107,8 @@ func TestClientChannel_ConnectAndSend(t *testing.T) {
defer srv.Close()
mb := bus.NewMessageBus()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: wsURL(srv.URL),
Token: *config.NewSecureString("test-token"),
SessionID: "sess-1",
@@ -137,7 +141,8 @@ func TestClientChannel_AuthFailure(t *testing.T) {
srv := testServer(t, "correct-token")
defer srv.Close()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: wsURL(srv.URL),
Token: *config.NewSecureString("wrong-token"),
}, bus.NewMessageBus())
@@ -161,7 +166,8 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) {
mb := bus.NewMessageBus()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: wsURL(srv.URL),
SessionID: "sess-echo",
ReadTimeout: 10,
@@ -203,7 +209,8 @@ func TestClientChannel_StartTyping(t *testing.T) {
srv := testServer(t, "")
defer srv.Close()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: wsURL(srv.URL),
SessionID: "sess-type",
ReadTimeout: 10,
@@ -231,7 +238,8 @@ func TestSend_ClosedConnection(t *testing.T) {
srv := testServer(t, "")
defer srv.Close()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: wsURL(srv.URL),
SessionID: "sess-close",
ReadTimeout: 10,
@@ -279,7 +287,8 @@ func TestParseInlineImageMedia_Valid(t *testing.T) {
func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) {
mb := bus.NewMessageBus()
ch, err := NewPicoChannel(config.PicoConfig{
bc := &config.Channel{Type: "pico", Enabled: true}
ch, err := NewPicoChannel(bc, &config.PicoSettings{
Token: *config.NewSecureString("test-token"),
}, mb)
if err != nil {
@@ -356,7 +365,8 @@ func TestIsThoughtPayload(t *testing.T) {
func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) {
mb := bus.NewMessageBus()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true}
ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{
URL: "ws://localhost:8080/ws",
}, mb)
if err != nil {
+44 -6
View File
@@ -7,10 +7,48 @@ import (
)
func init() {
channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewPicoChannel(cfg.Channels.Pico, b)
})
channels.RegisterFactory("pico_client", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewPicoClientChannel(cfg.Channels.PicoClient, b)
})
channels.RegisterFactory(
config.ChannelPico,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.PicoSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewPicoChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelPico {
ch.SetName(channelName)
}
return ch, nil
},
)
channels.RegisterFactory(
config.ChannelPicoClient,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.PicoClientSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewPicoClientChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelPicoClient {
ch.SetName(channelName)
}
return ch, nil
},
)
}
+11 -5
View File
@@ -70,7 +70,8 @@ func (pc *picoConn) close() {
// It serves as the reference implementation for all optional capability interfaces.
type PicoChannel struct {
*channels.BaseChannel
config config.PicoConfig
bc *config.Channel
config *config.PicoSettings
upgrader websocket.Upgrader
connections map[string]*picoConn // connID -> *picoConn
sessionConnections map[string]map[string]*picoConn // sessionID -> connID -> *picoConn
@@ -80,12 +81,16 @@ type PicoChannel struct {
}
// NewPicoChannel creates a new Pico Protocol channel.
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
func NewPicoChannel(
bc *config.Channel,
cfg *config.PicoSettings,
messageBus *bus.MessageBus,
) (*PicoChannel, error) {
if cfg.Token.String() == "" {
return nil, fmt.Errorf("pico token is required")
}
base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("pico", cfg, messageBus, bc.AllowFrom)
allowOrigins := cfg.AllowOrigins
checkOrigin := func(r *http.Request) bool {
@@ -103,6 +108,7 @@ func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoCha
return &PicoChannel{
BaseChannel: base,
bc: bc,
config: cfg,
upgrader: websocket.Upgrader{
CheckOrigin: checkOrigin,
@@ -289,11 +295,11 @@ func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), e
// It sends a placeholder message via the Pico Protocol that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
if !c.config.Placeholder.Enabled {
if !c.bc.Placeholder.Enabled {
return "", nil
}
text := c.config.Placeholder.GetRandomText()
text := c.bc.Placeholder.GetRandomText()
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
+3 -2
View File
@@ -15,9 +15,10 @@ import (
func newTestPicoChannel(t *testing.T) *PicoChannel {
t.Helper()
cfg := config.PicoConfig{}
bc := &config.Channel{Type: config.ChannelPico, Enabled: true}
cfg := &config.PicoSettings{}
cfg.SetToken("test-token")
ch, err := NewPicoChannel(cfg, bus.NewMessageBus())
ch, err := NewPicoChannel(bc, cfg, bus.NewMessageBus())
if err != nil {
t.Fatalf("NewPicoChannel: %v", err)
}
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewQQChannel(cfg.Channels.QQ, b)
})
channels.RegisterFactory(
config.ChannelQQ,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.QQSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewQQChannel(bc, c, b)
},
)
}
+9 -7
View File
@@ -56,7 +56,8 @@ type qqAPI interface {
type QQChannel struct {
*channels.BaseChannel
config config.QQConfig
bc *config.Channel
config *config.QQSettings
api qqAPI
tokenSource oauth2.TokenSource
ctx context.Context
@@ -82,15 +83,16 @@ type QQChannel struct {
stopOnce sync.Once
}
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
func NewQQChannel(bc *config.Channel, cfg *config.QQSettings, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(cfg.MaxMessageLength),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &QQChannel{
BaseChannel: base,
bc: bc,
config: cfg,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -161,8 +163,8 @@ func (c *QQChannel) Start(ctx context.Context) error {
// Pre-register reasoning_channel_id as group chat if configured,
// so outbound-only destinations are routed correctly.
if c.config.ReasoningChannelID != "" {
c.chatType.Store(c.config.ReasoningChannelID, "group")
if c.bc.ReasoningChannelID != "" {
c.chatType.Store(c.bc.ReasoningChannelID, "group")
}
c.SetRunning(true)
+8 -1
View File
@@ -198,6 +198,7 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) {
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: &config.QQSettings{},
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -294,6 +295,7 @@ func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: &config.QQSettings{},
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -329,6 +331,7 @@ func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) {
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: &config.QQSettings{},
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -374,6 +377,7 @@ func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: &config.QQSettings{},
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -409,6 +413,7 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: &config.QQSettings{},
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -481,6 +486,7 @@ func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) {
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: &config.QQSettings{},
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -520,6 +526,7 @@ func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: &config.QQSettings{},
api: &fakeQQAPI{},
dedup: make(map[string]time.Time),
done: make(chan struct{}),
@@ -566,7 +573,7 @@ func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testin
api := &fakeQQAPI{}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: config.QQConfig{
config: &config.QQSettings{
MaxBase64FileSizeMiB: 1,
},
api: api,
+47 -1
View File
@@ -1,6 +1,7 @@
package channels
import (
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -9,7 +10,9 @@ import (
// ChannelFactory is a constructor function that creates a Channel from config and message bus.
// Each channel subpackage registers one or more factories via init().
type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
// channelName is the config map key for this channel instance (may differ from the channel type).
// channelType is the channel type string used to look up the Channel config.
type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error)
var (
factoriesMu sync.RWMutex
@@ -23,6 +26,38 @@ func RegisterFactory(name string, f ChannelFactory) {
factories[name] = f
}
// RegisterSafeFactory is a convenience wrapper that handles GetDecoded() error checking
// and type assertion, reducing boilerplate in channel init() functions.
//
// Usage:
//
// func init() {
// channels.RegisterSafeFactory(config.ChannelTelegram,
// func(bc *config.Channel, c *config.TelegramSettings, b *bus.MessageBus) (channels.Channel, error) {
// return NewTelegramChannel(bc, c, b)
// })
// }
func RegisterSafeFactory[S any](
channelType string,
ctor func(bc *config.Channel, settings *S, bus *bus.MessageBus) (Channel, error),
) {
RegisterFactory(channelType, func(channelName, _ string, cfg *config.Config, b *bus.MessageBus) (Channel, error) {
bc := cfg.Channels[channelName]
if bc == nil {
return nil, fmt.Errorf("channel %q: config not found", channelName)
}
decoded, err := bc.GetDecoded()
if err != nil {
return nil, fmt.Errorf("channel %q: failed to decode settings: %w", channelName, err)
}
settings, ok := decoded.(*S)
if !ok {
return nil, fmt.Errorf("channel %q: expected %T settings, got %T", channelName, (*S)(nil), decoded)
}
return ctor(bc, settings, b)
})
}
// getFactory looks up a channel factory by name.
func getFactory(name string) (ChannelFactory, bool) {
factoriesMu.RLock()
@@ -30,3 +65,14 @@ func getFactory(name string) (ChannelFactory, bool) {
f, ok := factories[name]
return f, ok
}
// GetRegisteredFactoryNames returns a slice of all registered channel factory names.
func GetRegisteredFactoryNames() []string {
factoriesMu.RLock()
defer factoriesMu.RUnlock()
names := make([]string, 0, len(factories))
for name := range factories {
names = append(names, name)
}
return names
}
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewSlackChannel(cfg.Channels.Slack, b)
})
channels.RegisterFactory(
config.ChannelSlack,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.SlackSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewSlackChannel(bc, c, b)
},
)
}
+9 -5
View File
@@ -21,7 +21,7 @@ import (
type SlackChannel struct {
*channels.BaseChannel
config config.SlackConfig
config *config.SlackSettings
api *slack.Client
socketClient *socketmode.Client
botUserID string
@@ -36,7 +36,11 @@ type slackMessageRef struct {
Timestamp string
}
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
func NewSlackChannel(
bc *config.Channel,
cfg *config.SlackSettings,
messageBus *bus.MessageBus,
) (*SlackChannel, error) {
if cfg.BotToken.String() == "" || cfg.AppToken.String() == "" {
return nil, fmt.Errorf("slack bot_token and app_token are required")
}
@@ -48,10 +52,10 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
socketClient := socketmode.New(api)
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
base := channels.NewBaseChannel("slack", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(40000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &SlackChannel{
+14 -16
View File
@@ -100,32 +100,32 @@ func TestStripBotMention(t *testing.T) {
func TestNewSlackChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
bc := &config.Channel{Type: "slack", Enabled: true}
t.Run("missing bot token", func(t *testing.T) {
cfg := config.SlackConfig{}
cfg := &config.SlackSettings{}
cfg.AppToken = *config.NewSecureString("xapp-test")
_, err := NewSlackChannel(cfg, msgBus)
_, err := NewSlackChannel(bc, cfg, msgBus)
if err == nil {
t.Error("expected error for missing bot_token, got nil")
}
})
t.Run("missing app token", func(t *testing.T) {
cfg := config.SlackConfig{}
cfg := &config.SlackSettings{}
cfg.BotToken = *config.NewSecureString("xoxb-test")
_, err := NewSlackChannel(cfg, msgBus)
_, err := NewSlackChannel(bc, cfg, msgBus)
if err == nil {
t.Error("expected error for missing app_token, got nil")
}
})
t.Run("valid config", func(t *testing.T) {
cfg := config.SlackConfig{
AllowFrom: []string{"U123"},
}
cfg := &config.SlackSettings{}
cfg.BotToken = *config.NewSecureString("xoxb-test")
cfg.AppToken = *config.NewSecureString("xapp-test")
ch, err := NewSlackChannel(cfg, msgBus)
bc := &config.Channel{Type: "slack", Enabled: true, AllowFrom: []string{"U123"}}
ch, err := NewSlackChannel(bc, cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -142,24 +142,22 @@ func TestSlackChannelIsAllowed(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("empty allowlist allows all", func(t *testing.T) {
cfg := config.SlackConfig{
AllowFrom: []string{},
}
bc := &config.Channel{Type: config.ChannelSlack, Enabled: true, AllowFrom: []string{}}
cfg := &config.SlackSettings{}
cfg.BotToken = *config.NewSecureString("xoxb-test")
cfg.AppToken = *config.NewSecureString("xapp-test")
ch, _ := NewSlackChannel(cfg, msgBus)
ch, _ := NewSlackChannel(bc, cfg, msgBus)
if !ch.IsAllowed("U_ANYONE") {
t.Error("empty allowlist should allow all users")
}
})
t.Run("allowlist restricts users", func(t *testing.T) {
cfg := config.SlackConfig{
AllowFrom: []string{"U_ALLOWED"},
}
bc := &config.Channel{Type: config.ChannelSlack, Enabled: true, AllowFrom: []string{"U_ALLOWED"}}
cfg := &config.SlackSettings{}
cfg.BotToken = *config.NewSecureString("xoxb-test")
cfg.AppToken = *config.NewSecureString("xapp-test")
ch, _ := NewSlackChannel(cfg, msgBus)
ch, _ := NewSlackChannel(bc, cfg, msgBus)
if !ch.IsAllowed("U_ALLOWED") {
t.Error("allowed user should pass allowlist check")
}
+22 -3
View File
@@ -7,7 +7,26 @@ import (
)
func init() {
channels.RegisterFactory("teams_webhook", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewTeamsWebhookChannel(cfg.Channels.TeamsWebhook, b)
})
channels.RegisterFactory(
config.ChannelTeamsWebHook,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.TeamsWebhookSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewTeamsWebhookChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelTeamsWebHook {
ch.SetName(channelName)
}
return ch, nil
},
)
}
+5 -2
View File
@@ -52,13 +52,15 @@ func classifyTeamsError(err error) error {
// Multiple webhook targets can be configured and selected via ChatID.
type TeamsWebhookChannel struct {
*channels.BaseChannel
config config.TeamsWebhookConfig
bc *config.Channel
config *config.TeamsWebhookSettings
client teamsMessageSender
}
// NewTeamsWebhookChannel creates a new Teams webhook channel.
func NewTeamsWebhookChannel(
cfg config.TeamsWebhookConfig,
bc *config.Channel,
cfg *config.TeamsWebhookSettings,
bus *bus.MessageBus,
) (*TeamsWebhookChannel, error) {
if len(cfg.Webhooks) == 0 {
@@ -99,6 +101,7 @@ func NewTeamsWebhookChannel(
return &TeamsWebhookChannel{
BaseChannel: base,
bc: bc,
config: cfg,
client: client,
}, nil
@@ -31,67 +31,60 @@ func TestNewTeamsWebhookChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
// Test missing webhooks
_, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true}
cfg := config.TeamsWebhookSettings{
Webhooks: nil,
}, msgBus)
}
_, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err == nil {
t.Error("expected error for missing webhooks")
}
// Test missing "default" webhook
_, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
Webhooks: map[string]config.TeamsWebhookTarget{
"alerts": {
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
Title: "Alerts",
},
cfg.Webhooks = map[string]config.TeamsWebhookTarget{
"alerts": {
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
Title: "Alerts",
},
}, msgBus)
}
_, err = NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err == nil {
t.Error("expected error for missing 'default' webhook")
}
// Test empty webhook URL
_, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {Title: "Default"},
},
}, msgBus)
cfg.Webhooks = map[string]config.TeamsWebhookTarget{
"default": {Title: "Default"},
}
_, err = NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err == nil {
t.Error("expected error for empty webhook_url")
}
// Test HTTP URL (should fail, must be HTTPS)
_, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("http://example.com/webhook"),
Title: "Default",
},
cfg.Webhooks = map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("http://example.com/webhook"),
Title: "Default",
},
}, msgBus)
}
_, err = NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err == nil {
t.Error("expected error for HTTP webhook URL (must be HTTPS)")
}
// Test valid config with HTTPS (must include "default")
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
Title: "Default",
},
"alerts": {
WebhookURL: *config.NewSecureString("https://example.com/webhook1"),
Title: "Alerts",
},
cfg.Webhooks = map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
Title: "Default",
},
}, msgBus)
"alerts": {
WebhookURL: *config.NewSecureString("https://example.com/webhook1"),
Title: "Alerts",
},
}
ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -103,14 +96,15 @@ func TestNewTeamsWebhookChannel(t *testing.T) {
func TestTeamsWebhookChannel_StartStop(t *testing.T) {
msgBus := bus.NewMessageBus()
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true}
cfg := config.TeamsWebhookSettings{
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
},
},
}, msgBus)
}
ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -140,8 +134,8 @@ func TestTeamsWebhookChannel_StartStop(t *testing.T) {
func TestTeamsWebhookChannel_BuildAdaptiveCard(t *testing.T) {
msgBus := bus.NewMessageBus()
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true}
cfg := config.TeamsWebhookSettings{
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
@@ -152,7 +146,8 @@ func TestTeamsWebhookChannel_BuildAdaptiveCard(t *testing.T) {
Title: "Custom Title",
},
},
}, msgBus)
}
ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -175,14 +170,15 @@ func TestTeamsWebhookChannel_BuildAdaptiveCard(t *testing.T) {
func TestTeamsWebhookChannel_SendNotRunning(t *testing.T) {
msgBus := bus.NewMessageBus()
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true}
cfg := config.TeamsWebhookSettings{
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook"),
},
},
}, msgBus)
}
ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -208,8 +204,8 @@ func TestTeamsWebhookChannel_SendDefaultTargetFallback(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgBus := bus.NewMessageBus()
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true}
cfg := config.TeamsWebhookSettings{
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
@@ -218,7 +214,8 @@ func TestTeamsWebhookChannel_SendDefaultTargetFallback(t *testing.T) {
WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"),
},
},
}, msgBus)
}
ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -250,8 +247,8 @@ func TestTeamsWebhookChannel_SendDefaultTargetFallback(t *testing.T) {
func TestTeamsWebhookChannel_SendSuccess(t *testing.T) {
msgBus := bus.NewMessageBus()
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true}
cfg := config.TeamsWebhookSettings{
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
@@ -262,7 +259,8 @@ func TestTeamsWebhookChannel_SendSuccess(t *testing.T) {
Title: "Test Alerts",
},
},
}, msgBus)
}
ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -294,8 +292,8 @@ func TestTeamsWebhookChannel_SendSuccess(t *testing.T) {
func TestTeamsWebhookChannel_SendError(t *testing.T) {
msgBus := bus.NewMessageBus()
ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{
Enabled: true,
bc := &config.Channel{Type: config.ChannelTeamsWebHook, Enabled: true}
cfg := config.TeamsWebhookSettings{
Webhooks: map[string]config.TeamsWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://example.com/webhook-default"),
@@ -304,7 +302,8 @@ func TestTeamsWebhookChannel_SendError(t *testing.T) {
WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"),
},
},
}, msgBus)
}
ch, err := NewTeamsWebhookChannel(bc, &cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewTelegramChannel(cfg, b)
})
channels.RegisterFactory(
config.ChannelTelegram,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.TelegramSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewTelegramChannel(bc, c, b)
},
)
}
+19 -13
View File
@@ -47,18 +47,23 @@ type TelegramChannel struct {
*channels.BaseChannel
bot *telego.Bot
bh *th.BotHandler
config *config.Config
bc *config.Channel
chatIDs map[string]int64
ctx context.Context
cancel context.CancelFunc
tgCfg *config.TelegramSettings
registerFunc func(context.Context, []commands.Definition) error
commandRegCancel context.CancelFunc
}
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
func NewTelegramChannel(
bc *config.Channel,
telegramCfg *config.TelegramSettings,
bus *bus.MessageBus,
) (*TelegramChannel, error) {
channelName := bc.Name()
var opts []telego.BotOption
telegramCfg := cfg.Channels.Telegram
if telegramCfg.Proxy != "" {
proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
@@ -90,20 +95,21 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
}
base := channels.NewBaseChannel(
"telegram",
channelName,
telegramCfg,
bus,
telegramCfg.AllowFrom,
bc.AllowFrom,
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &TelegramChannel{
BaseChannel: base,
bot: bot,
config: cfg,
bc: bc,
chatIDs: make(map[string]int64),
tgCfg: telegramCfg,
}, nil
}
@@ -174,7 +180,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
return nil, channels.ErrNotRunning
}
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
useMarkdownV2 := c.tgCfg.UseMarkdownV2
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
@@ -360,7 +366,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
// EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
useMarkdownV2 := c.tgCfg.UseMarkdownV2
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return err
@@ -435,7 +441,7 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
phCfg := c.config.Channels.Telegram.Placeholder
phCfg := c.bc.Placeholder
if !phCfg.Enabled {
return "", nil
}
@@ -1063,7 +1069,7 @@ func (c *TelegramChannel) stripBotMention(content string) string {
// BeginStream implements channels.StreamingCapable.
func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
if !c.config.Channels.Telegram.Streaming.Enabled {
if !c.tgCfg.Streaming.Enabled {
return nil, fmt.Errorf("streaming disabled in config")
}
@@ -1072,7 +1078,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann
return nil, err
}
streamCfg := c.config.Channels.Telegram.Streaming
streamCfg := c.tgCfg.Streaming
return &telegramStreamer{
bot: c.bot,
chatID: cid,
+2 -1
View File
@@ -140,7 +140,8 @@ func newTestChannelWithConstructor(
BaseChannel: base,
bot: bot,
chatIDs: make(map[string]int64),
config: config.DefaultConfig(),
bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true},
tgCfg: &config.TelegramSettings{},
}
}
+10 -3
View File
@@ -7,7 +7,14 @@ import (
)
func init() {
channels.RegisterFactory("vk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewVKChannel(cfg, b)
})
channels.RegisterFactory(
config.ChannelVK,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
if bc == nil {
return nil, channels.ErrSendFailed
}
return NewVKChannel(channelName, bc, b)
},
)
}
+28 -15
View File
@@ -21,41 +21,54 @@ import (
type VKChannel struct {
*channels.BaseChannel
vk *api.VK
lp *longpoll.LongPoll
config *config.Config
ctx context.Context
cancel context.CancelFunc
vk *api.VK
lp *longpoll.LongPoll
channelName string
bc *config.Channel
ctx context.Context
cancel context.CancelFunc
}
func NewVKChannel(cfg *config.Config, bus *bus.MessageBus) (*VKChannel, error) {
vkCfg := cfg.Channels.VK
func NewVKChannel(channelName string, bc *config.Channel, bus *bus.MessageBus) (*VKChannel, error) {
var vkCfg config.VKSettings
if err := bc.Decode(&vkCfg); err != nil {
return nil, err
}
vk := api.NewVK(vkCfg.Token.String())
base := channels.NewBaseChannel(
"vk",
vkCfg,
channelName,
&vkCfg,
bus,
vkCfg.AllowFrom,
bc.AllowFrom,
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(vkCfg.GroupTrigger),
channels.WithReasoningChannelID(vkCfg.ReasoningChannelID),
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &VKChannel{
BaseChannel: base,
vk: vk,
config: cfg,
channelName: channelName,
bc: bc,
}, nil
}
func (c *VKChannel) getVKCfg() *config.VKSettings {
var v config.VKSettings
if err := c.bc.Decode(&v); err != nil {
return nil
}
return &v
}
func (c *VKChannel) Start(ctx context.Context) error {
logger.InfoC("vk", "Starting VK bot (Long Poll mode)...")
c.ctx, c.cancel = context.WithCancel(ctx)
groupID := c.config.Channels.VK.GroupID
groupID := c.getVKCfg().GroupID
if groupID == 0 {
c.cancel()
return fmt.Errorf("group_id is required for VK bot")
@@ -143,7 +156,7 @@ func (c *VKChannel) handleMessage(msg object.MessagesMessage) {
return
}
groupTrigger := c.config.Channels.VK.GroupTrigger
groupTrigger := c.bc.GroupTrigger
isGroupChat := peerID != fromID
if isGroupChat {
+54 -62
View File
@@ -1,6 +1,7 @@
package vk
import (
"encoding/json"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -8,19 +9,23 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
)
func makeVKTestBaseChannel(vkCfg config.VKSettings) *config.Channel {
settings, _ := json.Marshal(vkCfg)
return &config.Channel{
Enabled: true,
Type: config.ChannelVK,
Settings: settings,
}
}
func TestNewVKChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing group_id", func(t *testing.T) {
cfg := &config.Config{
Channels: config.ChannelsConfig{
VK: config.VKConfig{
Enabled: true,
Token: *config.NewSecureString("test_token"),
},
},
}
ch, err := NewVKChannel(cfg, msgBus)
bc := makeVKTestBaseChannel(config.VKSettings{
Token: *config.NewSecureString("test_token"),
})
ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error during creation: %v", err)
}
@@ -33,16 +38,11 @@ func TestNewVKChannel(t *testing.T) {
})
t.Run("valid config with group_id", func(t *testing.T) {
cfg := &config.Config{
Channels: config.ChannelsConfig{
VK: config.VKConfig{
Enabled: true,
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
},
},
}
ch, err := NewVKChannel(cfg, msgBus)
bc := makeVKTestBaseChannel(config.VKSettings{
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
})
ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -55,17 +55,18 @@ func TestNewVKChannel(t *testing.T) {
})
t.Run("with allow_from", func(t *testing.T) {
cfg := &config.Config{
Channels: config.ChannelsConfig{
VK: config.VKConfig{
Enabled: true,
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
AllowFrom: []string{"123456789"},
},
},
vkCfg := config.VKSettings{
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
}
ch, err := NewVKChannel(cfg, msgBus)
settings, _ := json.Marshal(vkCfg)
bc := &config.Channel{
Enabled: true,
Type: "vk",
AllowFrom: []string{"123456789"},
Settings: settings,
}
ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -78,20 +79,21 @@ func TestNewVKChannel(t *testing.T) {
})
t.Run("with group_trigger", func(t *testing.T) {
cfg := &config.Config{
Channels: config.ChannelsConfig{
VK: config.VKConfig{
Enabled: true,
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
GroupTrigger: config.GroupTriggerConfig{
MentionOnly: false,
Prefixes: []string{"/bot", "!bot"},
},
},
},
vkCfg := config.VKSettings{
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
}
ch, err := NewVKChannel(cfg, msgBus)
settings, _ := json.Marshal(vkCfg)
bc := &config.Channel{
Enabled: true,
Type: "vk",
GroupTrigger: config.GroupTriggerConfig{
MentionOnly: false,
Prefixes: []string{"/bot", "!bot"},
},
Settings: settings,
}
ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -103,16 +105,11 @@ func TestNewVKChannel(t *testing.T) {
func TestVKChannel_MaxMessageLength(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := &config.Config{
Channels: config.ChannelsConfig{
VK: config.VKConfig{
Enabled: true,
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
},
},
}
ch, err := NewVKChannel(cfg, msgBus)
bc := makeVKTestBaseChannel(config.VKSettings{
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
})
ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -236,16 +233,11 @@ func TestVKChannel_ProcessAttachments(t *testing.T) {
func TestVKChannel_VoiceCapabilities(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := &config.Config{
Channels: config.ChannelsConfig{
VK: config.VKConfig{
Enabled: true,
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
},
},
}
ch, err := NewVKChannel(cfg, msgBus)
bc := makeVKTestBaseChannel(config.VKSettings{
Token: *config.NewSecureString("test_token"),
GroupID: 123456789,
})
ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewChannel(cfg.Channels.WeCom, b)
})
channels.RegisterFactory(
config.ChannelWeCom,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.WeComSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewChannel(bc, c, b)
},
)
}
+4 -4
View File
@@ -34,7 +34,7 @@ const (
type WeComChannel struct {
*channels.BaseChannel
config config.WeComConfig
config *config.WeComSettings
ctx context.Context
cancel context.CancelFunc
@@ -108,7 +108,7 @@ func (s *recentMessageSet) Mark(id string) bool {
return true
}
func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChannel, error) {
func NewChannel(bc *config.Channel, cfg *config.WeComSettings, messageBus *bus.MessageBus) (*WeComChannel, error) {
if cfg.BotID == "" || cfg.Secret.String() == "" {
return nil, fmt.Errorf("wecom bot_id and secret are required")
}
@@ -120,8 +120,8 @@ func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChann
"wecom",
cfg,
messageBus,
cfg.AllowFrom,
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
bc.AllowFrom,
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
ch := &WeComChannel{
+3 -2
View File
@@ -605,9 +605,10 @@ func TestSendMedia_SendsActiveFile(t *testing.T) {
func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel {
t.Helper()
cfg := config.WeComConfig{BotID: "bot-1"}
cfg := &config.WeComSettings{BotID: "bot-1"}
cfg.SetSecret("secret-1")
ch, err := NewChannel(cfg, messageBus)
bc := &config.Channel{Type: config.ChannelWeCom, Enabled: true}
ch, err := NewChannel(bc, cfg, messageBus)
if err != nil {
t.Fatalf("NewChannel() error = %v", err)
}
+3 -3
View File
@@ -44,7 +44,7 @@ func picoclawHomeDir() string {
return config.GetHome()
}
func genWeixinAccountKey(cfg config.WeixinConfig) string {
func genWeixinAccountKey(cfg *config.WeixinSettings) string {
token := strings.TrimSpace(cfg.Token.String())
if token == "" {
return "default"
@@ -53,11 +53,11 @@ func genWeixinAccountKey(cfg config.WeixinConfig) string {
return hex.EncodeToString(sum[:8])
}
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
func buildWeixinSyncBufPath(cfg *config.WeixinSettings) string {
return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", genWeixinAccountKey(cfg)+".json")
}
func buildWeixinContextTokensPath(cfg config.WeixinConfig) string {
func buildWeixinContextTokensPath(cfg *config.WeixinSettings) string {
return filepath.Join(picoclawHomeDir(), "channels", "weixin", "context-tokens", genWeixinAccountKey(cfg)+".json")
}
+31 -8
View File
@@ -20,7 +20,7 @@ import (
type WeixinChannel struct {
*channels.BaseChannel
api *ApiClient
config config.WeixinConfig
config *config.WeixinSettings
ctx context.Context
cancel context.CancelFunc
bus *bus.MessageBus
@@ -36,25 +36,48 @@ type WeixinChannel struct {
}
func init() {
channels.RegisterFactory("weixin", func(cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) {
return NewWeixinChannel(cfg.Channels.Weixin, bus)
})
channels.RegisterFactory(
config.ChannelWeixin,
func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
weixinCfg, ok := decoded.(*config.WeixinSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewWeixinChannel(bc, weixinCfg, bus)
if err != nil {
return nil, err
}
if channelName != config.ChannelWeixin {
ch.SetName(channelName)
}
return ch, nil
},
)
}
// NewWeixinChannel creates a new WeixinChannel from config.
func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) {
func NewWeixinChannel(
bc *config.Channel,
cfg *config.WeixinSettings,
messageBus *bus.MessageBus,
) (*WeixinChannel, error) {
api, err := NewApiClient(cfg.BaseURL, cfg.Token.String(), cfg.Proxy)
if err != nil {
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
}
base := channels.NewBaseChannel(
"weixin",
bc.Name(),
cfg,
messageBus,
cfg.AllowFrom,
bc.AllowFrom,
channels.WithMaxMessageLength(4000),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &WeixinChannel{
+5 -5
View File
@@ -66,7 +66,7 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) {
}, nil
})},
},
config: config.WeixinConfig{
config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -105,7 +105,7 @@ func TestDownloadAndDecryptCDNBufferUsesFullURLWhenProvided(t *testing.T) {
return nil, nil
})},
},
config: config.WeixinConfig{
config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -155,7 +155,7 @@ func TestDownloadAndDecryptCDNBufferFallsBackToConstructedURLWhenFullURLFails(t
}, nil
})},
},
config: config.WeixinConfig{
config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -224,7 +224,7 @@ func TestUploadBufferToCDN(t *testing.T) {
}, nil
})},
},
config: config.WeixinConfig{
config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -259,7 +259,7 @@ func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) {
home := t.TempDir()
t.Setenv(config.EnvHome, home)
wxCfg := config.WeixinConfig{
wxCfg := &config.WeixinSettings{
BaseURL: "https://ilinkai.weixin.qq.com/",
}
wxCfg.SetToken("token-123")
+15 -3
View File
@@ -7,7 +7,19 @@ import (
)
func init() {
channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWhatsAppChannel(cfg.Channels.WhatsApp, b)
})
channels.RegisterFactory(
config.ChannelWhatsApp,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.WhatsAppSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewWhatsAppChannel(bc, c, b)
},
)
}
+8 -4
View File
@@ -20,7 +20,7 @@ import (
type WhatsAppChannel struct {
*channels.BaseChannel
conn *websocket.Conn
config config.WhatsAppConfig
config *config.WhatsAppSettings
url string
ctx context.Context
cancel context.CancelFunc
@@ -28,14 +28,18 @@ type WhatsAppChannel struct {
connected bool
}
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
func NewWhatsAppChannel(
bc *config.Channel,
cfg *config.WhatsAppSettings,
bus *bus.MessageBus,
) (*WhatsAppChannel, error) {
base := channels.NewBaseChannel(
"whatsapp",
cfg,
bus,
cfg.AllowFrom,
bc.AllowFrom,
channels.WithMaxMessageLength(65536),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &WhatsAppChannel{
@@ -12,7 +12,7 @@ import (
func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &WhatsAppChannel{
BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil),
BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppSettings{}, messageBus, nil),
ctx: context.Background(),
}
+23 -8
View File
@@ -9,12 +9,27 @@ import (
)
func init() {
channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
waCfg := cfg.Channels.WhatsApp
storePath := waCfg.SessionStorePath
if storePath == "" {
storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
}
return NewWhatsAppNativeChannel(waCfg, b, storePath)
})
channels.RegisterFactory(
config.ChannelWhatsAppNative,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.WhatsAppSettings)
if !ok {
return nil, channels.ErrSendFailed
}
storePath := c.SessionStorePath
if storePath == "" {
storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
}
ch, err := NewWhatsAppNativeChannel(bc, channelName, c, b, storePath)
if err != nil {
return nil, err
}
return ch, nil
},
)
}
@@ -20,7 +20,7 @@ import (
func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &WhatsAppNativeChannel{
BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil),
BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppSettings{}, messageBus, nil),
runCtx: context.Background(),
}
@@ -48,7 +48,7 @@ const (
// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).
type WhatsAppNativeChannel struct {
*channels.BaseChannel
config config.WhatsAppConfig
config *config.WhatsAppSettings
storePath string
client *whatsmeow.Client
container *sqlstore.Container
@@ -64,11 +64,13 @@ type WhatsAppNativeChannel struct {
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).
func NewWhatsAppNativeChannel(
cfg config.WhatsAppConfig,
bc *config.Channel,
name string,
cfg *config.WhatsAppSettings,
bus *bus.MessageBus,
storePath string,
) (channels.Channel, error) {
base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))
base := channels.NewBaseChannel(name, cfg, bus, bc.AllowFrom, channels.WithMaxMessageLength(65536))
if storePath == "" {
storePath = "whatsapp"
}
@@ -13,9 +13,16 @@ import (
// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.
// Build with: go build -tags whatsapp_native ./cmd/...
func NewWhatsAppNativeChannel(
cfg config.WhatsAppConfig,
bc *config.Channel,
name string,
cfg *config.WhatsAppSettings,
bus *bus.MessageBus,
storePath string,
) (channels.Channel, error) {
_ = bc
_ = name
_ = cfg
_ = bus
_ = storePath
return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native")
}
+194 -249
View File
@@ -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
+704
View File
@@ -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
}
+916
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+239 -293
View File
@@ -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
View File
@@ -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")
}
-36
View File
@@ -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
View File
@@ -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)
+73 -40
View File
@@ -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
View File
@@ -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)
})
}
+6 -3
View File
@@ -758,14 +758,17 @@ func setupCronTool(
// The PID file is the single source of truth for the pico auth token;
// it is generated once at gateway startup and remains unchanged across reloads.
func overridePicoToken(cfg *config.Config, token string) {
if !cfg.Channels.Pico.Enabled {
picoBC := cfg.Channels.GetByType(config.ChannelPico)
if picoBC == nil || !picoBC.Enabled {
return
}
picoToken := cfg.Channels.Pico.Token.String()
var picoCfg config.PicoSettings
picoBC.Decode(&picoCfg)
picoToken := picoCfg.Token.String()
if picoToken == "" || strings.HasPrefix(picoToken, pico.PicoTokenPrefix) {
return
}
cfg.Channels.Pico.SetToken(pico.PicoTokenPrefix + token + picoToken)
picoCfg.SetToken(pico.PicoTokenPrefix + token + picoToken)
}
func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult {
+148 -106
View File
@@ -1018,113 +1018,155 @@ func (c *PicoClawConfig) ToStandardConfig() *config.Config {
}
func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
return config.ChannelsConfig{
WhatsApp: config.WhatsAppConfig{
Enabled: c.WhatsApp.Enabled,
BridgeURL: c.WhatsApp.BridgeURL,
},
Telegram: func() config.TelegramConfig {
tc := config.TelegramConfig{
Enabled: c.Telegram.Enabled,
Proxy: c.Telegram.Proxy,
}
if c.Telegram.Token != "" {
tc.Token = *config.NewSecureString(c.Telegram.Token)
}
return tc
}(),
Feishu: func() config.FeishuConfig {
fc := config.FeishuConfig{
Enabled: c.Feishu.Enabled,
AppID: c.Feishu.AppID,
}
if c.Feishu.AppSecret != "" {
fc.AppSecret = *config.NewSecureString(c.Feishu.AppSecret)
}
if c.Feishu.EncryptKey != "" {
fc.EncryptKey = *config.NewSecureString(c.Feishu.EncryptKey)
}
if c.Feishu.VerificationToken != "" {
fc.VerificationToken = *config.NewSecureString(c.Feishu.VerificationToken)
}
return fc
}(),
Discord: func() config.DiscordConfig {
dc := config.DiscordConfig{
Enabled: c.Discord.Enabled,
MentionOnly: c.Discord.MentionOnly,
}
if c.Discord.Token != "" {
dc.Token = *config.NewSecureString(c.Discord.Token)
}
return dc
}(),
MaixCam: config.MaixCamConfig{
Enabled: c.MaixCam.Enabled,
Host: c.MaixCam.Host,
Port: c.MaixCam.Port,
},
QQ: func() config.QQConfig {
qc := config.QQConfig{
Enabled: c.QQ.Enabled,
AppID: c.QQ.AppID,
}
if c.QQ.AppSecret != "" {
qc.AppSecret = *config.NewSecureString(c.QQ.AppSecret)
}
return qc
}(),
DingTalk: func() config.DingTalkConfig {
dt := config.DingTalkConfig{
Enabled: c.DingTalk.Enabled,
ClientID: c.DingTalk.ClientID,
}
if c.DingTalk.ClientSecret != "" {
dt.ClientSecret = *config.NewSecureString(c.DingTalk.ClientSecret)
}
return dt
}(),
Slack: func() config.SlackConfig {
sc := config.SlackConfig{
Enabled: c.Slack.Enabled,
}
if c.Slack.BotToken != "" {
sc.BotToken = *config.NewSecureString(c.Slack.BotToken)
}
if c.Slack.AppToken != "" {
sc.AppToken = *config.NewSecureString(c.Slack.AppToken)
}
return sc
}(),
Matrix: func() config.MatrixConfig {
mc := config.MatrixConfig{
Enabled: c.Matrix.Enabled,
Homeserver: c.Matrix.Homeserver,
UserID: c.Matrix.UserID,
AllowFrom: c.Matrix.AllowFrom,
JoinOnInvite: true,
}
if c.Matrix.AccessToken != "" {
mc.AccessToken = *config.NewSecureString(c.Matrix.AccessToken)
}
return mc
}(),
LINE: func() config.LINEConfig {
lc := config.LINEConfig{
Enabled: c.LINE.Enabled,
WebhookHost: c.LINE.WebhookHost,
WebhookPort: c.LINE.WebhookPort,
WebhookPath: c.LINE.WebhookPath,
}
if c.LINE.ChannelSecret != "" {
lc.ChannelSecret = *config.NewSecureString(c.LINE.ChannelSecret)
}
if c.LINE.ChannelAccessToken != "" {
lc.ChannelAccessToken = *config.NewSecureString(c.LINE.ChannelAccessToken)
}
return lc
}(),
channels := make(config.ChannelsConfig)
setChannel(channels, "whatsapp", map[string]any{
"enabled": c.WhatsApp.Enabled,
"bridge_url": c.WhatsApp.BridgeURL,
})
setChannel(channels, "telegram", func() map[string]any {
m := map[string]any{
"enabled": c.Telegram.Enabled,
"proxy": c.Telegram.Proxy,
}
if c.Telegram.Token != "" {
m["token"] = config.NewSecureString(c.Telegram.Token)
}
return m
}())
setChannel(channels, "feishu", func() map[string]any {
m := map[string]any{
"enabled": c.Feishu.Enabled,
"app_id": c.Feishu.AppID,
}
if c.Feishu.AppSecret != "" {
m["app_secret"] = config.NewSecureString(c.Feishu.AppSecret)
}
if c.Feishu.EncryptKey != "" {
m["encrypt_key"] = config.NewSecureString(c.Feishu.EncryptKey)
}
if c.Feishu.VerificationToken != "" {
m["verification_token"] = config.NewSecureString(c.Feishu.VerificationToken)
}
return m
}())
setChannel(channels, "discord", func() map[string]any {
m := map[string]any{
"enabled": c.Discord.Enabled,
"mention_only": c.Discord.MentionOnly,
}
if c.Discord.Token != "" {
m["token"] = config.NewSecureString(c.Discord.Token)
}
return m
}())
setChannel(channels, "maixcam", map[string]any{
"enabled": c.MaixCam.Enabled,
"host": c.MaixCam.Host,
"port": c.MaixCam.Port,
})
setChannel(channels, "qq", func() map[string]any {
m := map[string]any{
"enabled": c.QQ.Enabled,
"app_id": c.QQ.AppID,
}
if c.QQ.AppSecret != "" {
m["app_secret"] = config.NewSecureString(c.QQ.AppSecret)
}
return m
}())
setChannel(channels, "dingtalk", func() map[string]any {
m := map[string]any{
"enabled": c.DingTalk.Enabled,
"client_id": c.DingTalk.ClientID,
}
if c.DingTalk.ClientSecret != "" {
m["client_secret"] = config.NewSecureString(c.DingTalk.ClientSecret)
}
return m
}())
setChannel(channels, "slack", func() map[string]any {
m := map[string]any{
"enabled": c.Slack.Enabled,
}
if c.Slack.BotToken != "" {
m["bot_token"] = config.NewSecureString(c.Slack.BotToken)
}
if c.Slack.AppToken != "" {
m["app_token"] = config.NewSecureString(c.Slack.AppToken)
}
return m
}())
setChannel(channels, "matrix", func() map[string]any {
m := map[string]any{
"enabled": c.Matrix.Enabled,
"homeserver": c.Matrix.Homeserver,
"user_id": c.Matrix.UserID,
"allow_from": c.Matrix.AllowFrom,
"join_on_invite": true,
}
if c.Matrix.AccessToken != "" {
m["access_token"] = config.NewSecureString(c.Matrix.AccessToken)
}
return m
}())
setChannel(channels, "line", func() map[string]any {
m := map[string]any{
"enabled": c.LINE.Enabled,
"webhook_host": c.LINE.WebhookHost,
"webhook_port": c.LINE.WebhookPort,
"webhook_path": c.LINE.WebhookPath,
}
if c.LINE.ChannelSecret != "" {
m["channel_secret"] = config.NewSecureString(c.LINE.ChannelSecret)
}
if c.LINE.ChannelAccessToken != "" {
m["channel_access_token"] = config.NewSecureString(c.LINE.ChannelAccessToken)
}
return m
}())
return channels
}
func setChannel(channels config.ChannelsConfig, name string, cfg any) {
data, err := json.Marshal(cfg)
if err != nil {
return
}
// Wrap in "settings" for nested format
var m map[string]any
if err = json.Unmarshal(data, &m); err != nil {
return
}
settings := make(map[string]any)
for k, v := range m {
if _, exists := config.BaseFieldNames[k]; !exists {
settings[k] = v
delete(m, k)
}
}
if len(settings) > 0 {
m["settings"] = settings
}
nestedData, err := json.Marshal(m)
if err != nil {
return
}
bc := &config.Channel{}
if err := json.Unmarshal(nestedData, bc); err != nil {
return
}
channels[name] = bc
}
func (c GatewayConfig) ToStandardGateway() config.GatewayConfig {
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestLoadOpenClawConfig(t *testing.T) {
@@ -708,11 +710,16 @@ func TestToStandardConfig(t *testing.T) {
t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey)
}
if !stdCfg.Channels.Telegram.Enabled {
if !stdCfg.Channels["telegram"].Enabled {
t.Error("telegram should be enabled")
}
if stdCfg.Channels.Telegram.Token.String() != "test-token" {
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token.String())
decoded, err := stdCfg.Channels["telegram"].GetDecoded()
if err != nil {
t.Fatalf("GetDecoded() error = %v", err)
}
if tCfg, ok := decoded.(*config.TelegramSettings); ok &&
tCfg.Token.String() != "test-token" {
t.Errorf("expected token 'test-token', got '%s'", tCfg.Token.String())
}
if stdCfg.Gateway.Port != 8080 {