mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge pull request #2481 from cytown/channel
refactor(config): make config.Channel to multiple instance support
This commit is contained in:
+80
-33
@@ -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
@@ -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()` 中通过名字查找工厂并调用它。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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**")
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user