From c4c259c748e9e8a97cd6853c19294eda6c89ab3d Mon Sep 17 00:00:00 2001 From: Ethan Wang Date: Mon, 20 Apr 2026 13:04:13 +0000 Subject: [PATCH] feat: enhance Weixin channel support and configuration - Added tests for listing Weixin channel catalog and retrieving dynamic Weixin instance configurations. - Implemented Weixin channel handling in the backend, including validation and parsing of channel names. - Updated Weixin flow handling to support multiple instances and added error handling for invalid channel names. - Enhanced frontend components to allow creation of new Weixin channels with validation and error messages. - Updated localization files to include new strings for Weixin channel management. - Added unit tests for Weixin configuration management to ensure proper handling of multiple channels and type conflicts. --- cmd/picoclaw/internal/auth/weixin.go | 51 ++++-- cmd/picoclaw/internal/auth/weixin_test.go | 98 ++++++++++ docs/channels/weixin/README.md | 33 +++- docs/channels/weixin/README.zh.md | 33 +++- pkg/channels/weixin/media.go | 2 +- pkg/channels/weixin/state.go | 26 +++ pkg/channels/weixin/weixin.go | 29 ++- pkg/channels/weixin/weixin_test.go | 118 ++++++++++++ web/backend/api/channels.go | 124 ++++++++++--- web/backend/api/channels_test.go | 116 ++++++++++++ web/backend/api/weixin.go | 109 ++++++++++- web/backend/api/weixin_test.go | 122 +++++++++++- web/frontend/src/api/channels.ts | 15 +- .../channels/channel-config-page.tsx | 173 ++++++++++++++++-- .../channels/channel-forms/weixin-form.tsx | 6 +- .../src/hooks/use-sidebar-channels.ts | 21 ++- web/frontend/src/i18n/locales/en.json | 7 + web/frontend/src/i18n/locales/zh.json | 7 + 18 files changed, 1011 insertions(+), 79 deletions(-) create mode 100644 cmd/picoclaw/internal/auth/weixin_test.go diff --git a/cmd/picoclaw/internal/auth/weixin.go b/cmd/picoclaw/internal/auth/weixin.go index 0d060a5fe..c285f94a0 100644 --- a/cmd/picoclaw/internal/auth/weixin.go +++ b/cmd/picoclaw/internal/auth/weixin.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "strings" "time" "github.com/spf13/cobra" @@ -14,6 +15,7 @@ import ( func newWeixinCommand() *cobra.Command { var baseURL string + var channelName string var proxy string var timeout int @@ -29,18 +31,19 @@ config so you can start the gateway immediately. Example: picoclaw auth weixin`, RunE: func(cmd *cobra.Command, _ []string) error { - return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second) + return runWeixinOnboard(channelName, baseURL, proxy, time.Duration(timeout)*time.Second) }, } cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL") + cmd.Flags().StringVar(&channelName, "channel", config.ChannelWeixin, "Channel name to create or update") cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)") cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds") return cmd } -func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error { +func runWeixinOnboard(channelName, baseURL, proxy string, timeout time.Duration) error { fmt.Println("Starting Weixin (WeChat personal) login...") fmt.Println() @@ -70,9 +73,9 @@ func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error { effectiveBaseURL = baseURL } - if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil { + if err := saveWeixinConfig(channelName, botToken, accountID, effectiveBaseURL, proxy); err != nil { fmt.Printf("⚠️ Could not auto-save to config: %v\n", err) - printManualWeixinConfig(botToken, effectiveBaseURL) + printManualWeixinConfig(channelName, botToken, accountID, effectiveBaseURL) return nil } @@ -81,32 +84,49 @@ func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error { fmt.Println(" picoclaw gateway") fmt.Println() fmt.Println("To restrict which WeChat users can send messages, add their user IDs") - fmt.Println("to channels.weixin.allow_from in your config.") + channelName = normalizeWeixinChannelName(channelName) + fmt.Printf("to channels.%s.allow_from in your config.\n", channelName) return nil } -// saveWeixinConfig patches channels.weixin in the config and saves it. -func saveWeixinConfig(token, baseURL, proxy string) error { +func normalizeWeixinChannelName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return config.ChannelWeixin + } + return name +} + +// saveWeixinConfig patches the named Weixin channel in the config and saves it. +func saveWeixinConfig(channelName, token, accountID, baseURL, proxy string) error { cfgPath := internal.GetConfigPath() + channelName = normalizeWeixinChannelName(channelName) cfg, err := config.LoadConfig(cfgPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) } + if cfg.Channels == nil { + cfg.Channels = config.ChannelsConfig{} + } - bc := cfg.Channels.GetByType(config.ChannelWeixin) + bc := cfg.Channels.Get(channelName) if bc == nil { bc = &config.Channel{Type: config.ChannelWeixin} - cfg.Channels[config.ChannelWeixin] = bc + cfg.Channels[channelName] = bc } + if bc.Type != "" && bc.Type != config.ChannelWeixin { + return fmt.Errorf("channel %q already exists with type %q", channelName, bc.Type) + } + bc.Type = config.ChannelWeixin bc.Enabled = true if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { if weixinCfg, ok := decoded.(*config.WeixinSettings); ok { weixinCfg.Token = *config.NewSecureString(token) - const defaultBase = "https://ilinkai.weixin.qq.com/" - if baseURL != "" && baseURL != defaultBase { + weixinCfg.AccountID = accountID + if baseURL != "" { weixinCfg.BaseURL = baseURL } if proxy != "" { @@ -118,13 +138,18 @@ func saveWeixinConfig(token, baseURL, proxy string) error { return config.SaveConfig(cfgPath, cfg) } -func printManualWeixinConfig(token, baseURL string) { +func printManualWeixinConfig(channelName, token, accountID, baseURL string) { + channelName = normalizeWeixinChannelName(channelName) fmt.Println() fmt.Println("Add the following to the channels section of your picoclaw config:") fmt.Println() - fmt.Println(` "weixin": {`) + fmt.Printf(" %q: {\n", channelName) fmt.Println(` "enabled": true,`) + fmt.Println(` "type": "weixin",`) fmt.Printf(" \"token\": %q,\n", token) + if accountID != "" { + fmt.Printf(" \"account_id\": %q,\n", accountID) + } const defaultBase = "https://ilinkai.weixin.qq.com/" if baseURL != "" && baseURL != defaultBase { fmt.Printf(" \"base_url\": %q,\n", baseURL) diff --git a/cmd/picoclaw/internal/auth/weixin_test.go b/cmd/picoclaw/internal/auth/weixin_test.go new file mode 100644 index 000000000..3e7964e85 --- /dev/null +++ b/cmd/picoclaw/internal/auth/weixin_test.go @@ -0,0 +1,98 @@ +package auth + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewWeixinCommandHasChannelFlag(t *testing.T) { + cmd := newWeixinCommand() + + flag := cmd.Flags().Lookup("channel") + require.NotNil(t, flag) + assert.Equal(t, config.ChannelWeixin, flag.DefValue) +} + +func TestSaveWeixinConfigCreatesNamedChannel(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv(config.EnvHome, tmpDir) + t.Setenv(config.EnvConfig, configPath) + + err := saveWeixinConfig( + "weixin_personal", + "token-personal", + "account-personal", + "https://region.example.com/", + "http://127.0.0.1:7890", + ) + require.NoError(t, err) + + cfg, err := config.LoadConfig(internal.GetConfigPath()) + require.NoError(t, err) + bc := cfg.Channels.Get("weixin_personal") + require.NotNil(t, bc) + assert.True(t, bc.Enabled) + assert.Equal(t, config.ChannelWeixin, bc.Type) + + decoded, err := bc.GetDecoded() + require.NoError(t, err) + wxCfg := decoded.(*config.WeixinSettings) + assert.Equal(t, "token-personal", wxCfg.Token.String()) + assert.Equal(t, "account-personal", wxCfg.AccountID) + assert.Equal(t, "https://region.example.com/", wxCfg.BaseURL) + assert.Equal(t, "http://127.0.0.1:7890", wxCfg.Proxy) +} + +func TestSaveWeixinConfigDoesNotOverwriteOtherWeixinChannels(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv(config.EnvHome, tmpDir) + t.Setenv(config.EnvConfig, configPath) + + require.NoError(t, saveWeixinConfig("weixin_a", "token-a", "account-a", "", "")) + require.NoError(t, saveWeixinConfig("weixin_b", "token-b", "account-b", "", "")) + + cfg, err := config.LoadConfig(internal.GetConfigPath()) + require.NoError(t, err) + + a := cfg.Channels.Get("weixin_a") + require.NotNil(t, a) + aDecoded, err := a.GetDecoded() + require.NoError(t, err) + aCfg := aDecoded.(*config.WeixinSettings) + assert.Equal(t, "token-a", aCfg.Token.String()) + assert.Equal(t, "account-a", aCfg.AccountID) + + b := cfg.Channels.Get("weixin_b") + require.NotNil(t, b) + bDecoded, err := b.GetDecoded() + require.NoError(t, err) + bCfg := bDecoded.(*config.WeixinSettings) + assert.Equal(t, "token-b", bCfg.Token.String()) + assert.Equal(t, "account-b", bCfg.AccountID) +} + +func TestSaveWeixinConfigRejectsExistingDifferentType(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv(config.EnvHome, tmpDir) + t.Setenv(config.EnvConfig, configPath) + + cfg := config.DefaultConfig() + cfg.Channels["telegram_alias"] = &config.Channel{ + Enabled: true, + Type: config.ChannelTelegram, + } + require.NoError(t, config.SaveConfig(configPath, cfg)) + + err := saveWeixinConfig("telegram_alias", "token", "account", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists with type") +} diff --git a/docs/channels/weixin/README.md b/docs/channels/weixin/README.md index 4e240d69b..1ec560dcb 100644 --- a/docs/channels/weixin/README.md +++ b/docs/channels/weixin/README.md @@ -21,6 +21,13 @@ After onboarding, you can start the gateway: picoclaw gateway ``` +To bind more than one WeChat account, give each account its own channel name: + +```bash +picoclaw auth weixin --channel weixin_personal +picoclaw auth weixin --channel weixin_work +``` + --- ## ⚙️ Configuration @@ -44,16 +51,40 @@ You can also manually configure the filter rules in `config.json` under the `cha } ``` +Multiple Weixin channels can be configured by using different map keys with `type: "weixin"`: + +```json +{ + "channel_list": { + "weixin_personal": { + "enabled": true, + "type": "weixin", + "token": "TOKEN_A", + "account_id": "ACCOUNT_A", + "allow_from": ["user_id_1"] + }, + "weixin_work": { + "enabled": true, + "type": "weixin", + "token": "TOKEN_B", + "account_id": "ACCOUNT_B", + "allow_from": ["user_id_2"] + } + } +} +``` + ### Configuration Fields | Field | Description | |---|---| | `enabled` | Set to `true` to enable the channel at startup. | | `token` | The authentication token obtained via QR login. | +| `account_id` | (Optional) Stable account identifier returned by QR login. Used to keep per-account state files stable. | | `allow_from` | (Optional) List of WeChat User IDs permitted to interact with the bot. If empty, anyone who can send messages to the connected account can trigger the bot. | | `proxy` | (Optional) HTTP proxy address (e.g. `http://localhost:7890`) for environments where connection to `ilinkai.weixin.qq.com` is restricted. | ## ⚠️ Important Notes -- **One Account Only**: The iLink token binds to a single session. Starting a new interaction generally invalidates older tokens if another device authorizes. +- **One token per account**: Each iLink token binds to a single WeChat account session. Bind multiple accounts as separate channels. - **Message Rate Limits**: To avoid getting your account restricted by WeChat anti-spam systems, avoid loop triggers or high-frequency broadcasts. diff --git a/docs/channels/weixin/README.zh.md b/docs/channels/weixin/README.zh.md index 19a9f9fa2..a35e9ee9b 100644 --- a/docs/channels/weixin/README.zh.md +++ b/docs/channels/weixin/README.zh.md @@ -21,6 +21,13 @@ picoclaw auth weixin picoclaw gateway ``` +如果要绑定多个微信账号,请为每个账号指定独立的 channel 名称: + +```bash +picoclaw auth weixin --channel weixin_personal +picoclaw auth weixin --channel weixin_work +``` + --- ## ⚙️ 配置说明 @@ -44,16 +51,40 @@ picoclaw gateway } ``` +多个微信账号可以通过不同的配置 key + `type: "weixin"` 来配置: + +```json +{ + "channel_list": { + "weixin_personal": { + "enabled": true, + "type": "weixin", + "token": "TOKEN_A", + "account_id": "ACCOUNT_A", + "allow_from": ["user_id_1"] + }, + "weixin_work": { + "enabled": true, + "type": "weixin", + "token": "TOKEN_B", + "account_id": "ACCOUNT_B", + "allow_from": ["user_id_2"] + } + } +} +``` + ### 字段解析 | 字段 | 说明 | |---|---| | `enabled` | 设置为 `true` 以在启动时激活该频道。 | | `token` | 通过扫码获取的认证令牌。 | +| `account_id` | (可选) 扫码返回的稳定账号标识,用于保持账号级状态文件路径稳定。 | | `allow_from` | (可选) 允许与机器人交互的微信 User ID 列表。如果为空,任何能给此微信号发消息的人都可以触发机器人。 | | `proxy` | (可选) HTTP 代理地址(例如 `http://localhost:7890`),适合网络访问受限环境。 | ## ⚠️ 注意事项 -- **单端绑定**: iLink 令牌通常与单个会话绑定。在其他地方重新扫码激活可能会导致旧令牌失效。 +- **一个账号一个 token**: iLink 令牌通常与单个微信账号会话绑定。多个微信账号请作为多个 channel 分别绑定。 - **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。 diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go index cf1b45612..1a894455d 100644 --- a/pkg/channels/weixin/media.go +++ b/pkg/channels/weixin/media.go @@ -383,7 +383,7 @@ func (c *WeixinChannel) storeInboundBytes( ContentType: contentType, Source: "weixin", CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, - }, basechannels.BuildMediaScope("weixin", chatID, messageID)) + }, basechannels.BuildMediaScope(c.channelName(), chatID, messageID)) if err != nil { os.Remove(tmpPath) return "", err diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go index 0f8257895..bf26e0429 100644 --- a/pkg/channels/weixin/state.go +++ b/pkg/channels/weixin/state.go @@ -44,7 +44,33 @@ func picoclawHomeDir() string { return config.GetHome() } +func sanitizeWeixinAccountKey(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + var b strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return strings.Trim(b.String(), "._-") +} + func genWeixinAccountKey(cfg *config.WeixinSettings) string { + if cfg == nil { + return "default" + } + if accountID := sanitizeWeixinAccountKey(cfg.AccountID); accountID != "" { + return accountID + } token := strings.TrimSpace(cfg.Token.String()) if token == "" { return "default" diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 2897d2422..a693e5cef 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -97,7 +97,10 @@ func (c *WeixinChannel) Start(ctx context.Context) error { c.SetRunning(true) c.restoreContextTokens() go c.pollLoop(c.ctx) - logger.InfoC("weixin", "Weixin channel started") + logger.InfoCF("weixin", "Weixin channel started", map[string]any{ + "channel": c.channelName(), + "account_id": c.accountID(), + }) return nil } @@ -151,6 +154,26 @@ func (c *WeixinChannel) Stop(ctx context.Context) error { return nil } +func (c *WeixinChannel) channelName() string { + if c != nil && c.BaseChannel != nil { + name := strings.TrimSpace(c.Name()) + if name != "" { + return name + } + } + return config.ChannelWeixin +} + +func (c *WeixinChannel) accountID() string { + if c != nil && c.config != nil { + accountID := strings.TrimSpace(c.config.AccountID) + if accountID != "" { + return accountID + } + } + return "" +} + // pollLoop is the long-poll receive loop. It runs until ctx is canceled. func (c *WeixinChannel) pollLoop(ctx context.Context) { const ( @@ -358,6 +381,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess } metadata := map[string]string{ + "account_id": c.accountID(), "from_user_id": fromUserID, "context_token": msg.ContextToken, "session_id": msg.SessionID, @@ -376,7 +400,8 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess } inboundCtx := bus.InboundContext{ - Channel: "weixin", + Channel: c.channelName(), + Account: c.accountID(), ChatID: fromUserID, ChatType: "direct", SenderID: fromUserID, diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go index aea2cbb0c..c9935c081 100644 --- a/pkg/channels/weixin/weixin_test.go +++ b/pkg/channels/weixin/weixin_test.go @@ -8,11 +8,14 @@ import ( "io" "net/http" "path/filepath" + "strings" "testing" "time" + "github.com/sipeed/picoclaw/pkg/bus" basechannels "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) type roundTripFunc func(*http.Request) (*http.Response, error) @@ -269,6 +272,121 @@ func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) { } } +func TestBuildWeixinSyncBufPathUsesAccountIDWhenAvailable(t *testing.T) { + home := t.TempDir() + t.Setenv(config.EnvHome, home) + + wxCfg := &config.WeixinSettings{ + AccountID: "wx.account/one", + BaseURL: "https://ilinkai.weixin.qq.com/", + } + wxCfg.SetToken("token-123") + + got := buildWeixinSyncBufPath(wxCfg) + if filepath.Base(got) != "wx.account_one.json" { + t.Fatalf("sync path base = %q, want wx.account_one.json", filepath.Base(got)) + } +} + +func TestHandleInboundMessageUsesChannelInstanceAndAccount(t *testing.T) { + home := t.TempDir() + t.Setenv(config.EnvHome, home) + + msgBus := bus.NewMessageBus() + wxCfg := &config.WeixinSettings{ + AccountID: "account-a", + BaseURL: "https://ilinkai.weixin.qq.com/", + } + wxCfg.SetToken("token-a") + bc := &config.Channel{Enabled: true, Type: config.ChannelWeixin} + bc.SetName("weixin_a") + ch, err := NewWeixinChannel(bc, wxCfg, msgBus) + if err != nil { + t.Fatalf("NewWeixinChannel() error = %v", err) + } + ch.SetName("weixin_a") + + ch.handleInboundMessage(context.Background(), WeixinMessage{ + FromUserID: "user-1", + ClientID: "msg-1", + ContextToken: "ctx-1", + SessionID: "session-1", + ItemList: []MessageItem{ + { + Type: MessageItemTypeText, + TextItem: &TextItem{Text: "hello"}, + }, + }, + }) + + select { + case got := <-msgBus.InboundChan(): + if got.Context.Channel != "weixin_a" { + t.Fatalf("Context.Channel = %q, want weixin_a", got.Context.Channel) + } + if got.Context.Account != "account-a" { + t.Fatalf("Context.Account = %q, want account-a", got.Context.Account) + } + if got.Context.ChatID != "user-1" { + t.Fatalf("Context.ChatID = %q, want user-1", got.Context.ChatID) + } + if got.Context.ChatType != "direct" { + t.Fatalf("Context.ChatType = %q, want direct", got.Context.ChatType) + } + if got.Content != "hello" { + t.Fatalf("Content = %q, want hello", got.Content) + } + if got.Context.Raw["account_id"] != "account-a" { + t.Fatalf("raw account_id = %q, want account-a", got.Context.Raw["account_id"]) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for inbound message") + } +} + +type recordingMediaStore struct { + scope string +} + +func (s *recordingMediaStore) Store(_ string, _ media.MediaMeta, scope string) (string, error) { + s.scope = scope + return "media://ref", nil +} + +func (s *recordingMediaStore) Resolve(ref string) (string, error) { + return "", nil +} + +func (s *recordingMediaStore) ResolveWithMeta(ref string) (string, media.MediaMeta, error) { + return "", media.MediaMeta{}, nil +} + +func (s *recordingMediaStore) ReleaseAll(scope string) error { + return nil +} + +func TestStoreInboundBytesUsesChannelInstanceInScope(t *testing.T) { + msgBus := bus.NewMessageBus() + wxCfg := &config.WeixinSettings{BaseURL: "https://ilinkai.weixin.qq.com/"} + wxCfg.SetToken("token-a") + bc := &config.Channel{Enabled: true, Type: config.ChannelWeixin} + bc.SetName("weixin_a") + ch, err := NewWeixinChannel(bc, wxCfg, msgBus) + if err != nil { + t.Fatalf("NewWeixinChannel() error = %v", err) + } + ch.SetName("weixin_a") + store := &recordingMediaStore{} + ch.SetMediaStore(store) + + if _, err := ch.storeInboundBytes("user-1", "msg-1", "file.txt", "text/plain", []byte("hello")); err != nil { + t.Fatalf("storeInboundBytes() error = %v", err) + } + if !strings.HasPrefix(store.scope, "weixin_a:user-1:msg-1") { + t.Fatalf("media scope = %q, want weixin_a:user-1:msg-1 prefix", store.scope) + } +} + func TestSessionPauseGuard(t *testing.T) { ch := &WeixinChannel{ typingCache: make(map[string]typingTicketCacheEntry), diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index 82cd54b72..f81c40c25 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -3,33 +3,39 @@ package api import ( "encoding/json" "net/http" + "sort" + "strings" "github.com/sipeed/picoclaw/pkg/config" ) type channelCatalogItem struct { - Name string `json:"name"` - ConfigKey string `json:"config_key"` - Variant string `json:"variant,omitempty"` + Name string `json:"name"` + Type string `json:"type,omitempty"` + DisplayName string `json:"display_name,omitempty"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant,omitempty"` + Template bool `json:"template,omitempty"` + SupportsMultiple bool `json:"supports_multiple,omitempty"` } var channelCatalog = []channelCatalogItem{ - {Name: "weixin", ConfigKey: "weixin"}, - {Name: "telegram", ConfigKey: "telegram"}, - {Name: "discord", ConfigKey: "discord"}, - {Name: "slack", ConfigKey: "slack"}, - {Name: "feishu", ConfigKey: "feishu"}, - {Name: "dingtalk", ConfigKey: "dingtalk"}, - {Name: "line", ConfigKey: "line"}, - {Name: "qq", ConfigKey: "qq"}, - {Name: "onebot", ConfigKey: "onebot"}, - {Name: "wecom", ConfigKey: "wecom"}, - {Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, - {Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, - {Name: "pico", ConfigKey: "pico"}, - {Name: "maixcam", ConfigKey: "maixcam"}, - {Name: "matrix", ConfigKey: "matrix"}, - {Name: "irc", ConfigKey: "irc"}, + {Name: "weixin", Type: "weixin", ConfigKey: "weixin", SupportsMultiple: true}, + {Name: "telegram", Type: "telegram", ConfigKey: "telegram"}, + {Name: "discord", Type: "discord", ConfigKey: "discord"}, + {Name: "slack", Type: "slack", ConfigKey: "slack"}, + {Name: "feishu", Type: "feishu", ConfigKey: "feishu"}, + {Name: "dingtalk", Type: "dingtalk", ConfigKey: "dingtalk"}, + {Name: "line", Type: "line", ConfigKey: "line"}, + {Name: "qq", Type: "qq", ConfigKey: "qq"}, + {Name: "onebot", Type: "onebot", ConfigKey: "onebot"}, + {Name: "wecom", Type: "wecom", ConfigKey: "wecom"}, + {Name: "whatsapp", Type: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, + {Name: "whatsapp_native", Type: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, + {Name: "pico", Type: "pico", ConfigKey: "pico"}, + {Name: "maixcam", Type: "maixcam", ConfigKey: "maixcam"}, + {Name: "matrix", Type: "matrix", ConfigKey: "matrix"}, + {Name: "irc", Type: "irc", ConfigKey: "irc"}, } type channelConfigResponse struct { @@ -49,9 +55,14 @@ func (h *Handler) registerChannelRoutes(mux *http.ServeMux) { // // GET /api/channels/catalog func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, "Failed to load config", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "channels": channelCatalog, + "channels": buildChannelCatalog(cfg), }) } @@ -60,11 +71,6 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques // GET /api/channels/{name}/config func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { channelName := r.PathValue("name") - item, ok := findChannelCatalogItem(channelName) - if !ok { - http.Error(w, "Channel not found", http.StatusNotFound) - return - } cfg, err := config.LoadConfig(h.configPath) if err != nil { @@ -72,6 +78,12 @@ func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) return } + item, ok := findChannelCatalogItem(buildChannelCatalog(cfg), channelName) + if !ok { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + resp := buildChannelConfigResponse(cfg, item) w.Header().Set("Content-Type", "application/json") @@ -80,8 +92,8 @@ func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) } } -func findChannelCatalogItem(name string) (channelCatalogItem, bool) { - for _, item := range channelCatalog { +func findChannelCatalogItem(items []channelCatalogItem, name string) (channelCatalogItem, bool) { + for _, item := range items { if item.Name == name { return item, true } @@ -89,6 +101,55 @@ func findChannelCatalogItem(name string) (channelCatalogItem, bool) { return channelCatalogItem{}, false } +func buildChannelCatalog(cfg *config.Config) []channelCatalogItem { + items := append([]channelCatalogItem(nil), channelCatalog...) + if cfg == nil || cfg.Channels == nil { + return items + } + + knownNames := make(map[string]struct{}, len(items)) + for _, item := range items { + knownNames[item.Name] = struct{}{} + } + + var dynamic []channelCatalogItem + for name, bc := range cfg.Channels { + if strings.TrimSpace(name) == "" || bc == nil { + continue + } + if _, exists := knownNames[name]; exists { + continue + } + typeName := strings.TrimSpace(bc.Type) + if typeName == "" { + typeName = name + } + if !supportsMultipleChannelType(typeName) { + continue + } + dynamic = append(dynamic, channelCatalogItem{ + Name: name, + Type: typeName, + ConfigKey: name, + SupportsMultiple: supportsMultipleChannelType(typeName), + }) + } + sort.Slice(dynamic, func(i, j int) bool { + return dynamic[i].Name < dynamic[j].Name + }) + items = append(items, dynamic...) + return items +} + +func supportsMultipleChannelType(typeName string) bool { + switch strings.TrimSpace(typeName) { + case config.ChannelWeixin: + return true + default: + return false + } +} + var channelSecretFieldMap = map[string][]string{ "weixin": {"token"}, "telegram": {"token"}, @@ -118,6 +179,9 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha bc := cfg.Channels.Get(item.ConfigKey) if bc == nil { bc = defaultChannelConfig(item.ConfigKey) + if bc == nil && item.Type != "" && item.Type != item.ConfigKey { + bc = defaultChannelConfig(item.Type) + } if bc == nil { resp.Config = map[string]any{} return resp @@ -125,7 +189,11 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha } // Detect configured secrets by checking the raw Settings JSON - secrets := detectConfiguredSecrets(bc.Settings, item.Name) + typeName := item.Type + if strings.TrimSpace(typeName) == "" { + typeName = item.Name + } + secrets := detectConfiguredSecrets(bc.Settings, typeName) resp.ConfiguredSecrets = secrets // Parse settings into a generic map for JSON response diff --git a/web/backend/api/channels_test.go b/web/backend/api/channels_test.go index 0208af8e7..b7f1b9ca6 100644 --- a/web/backend/api/channels_test.go +++ b/web/backend/api/channels_test.go @@ -193,3 +193,119 @@ func TestHandleGetChannelConfig_ReturnsDefaultShapeForMissingChannel(t *testing. t.Fatalf("config.enabled = %#v, want false", got) } } + +func TestHandleListChannelCatalog_IncludesExistingWeixinInstances(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Channels["weixin_work"] = &config.Channel{ + Enabled: true, + Type: config.ChannelWeixin, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/catalog", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/channels/catalog status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp struct { + Channels []struct { + Name string `json:"name"` + Type string `json:"type"` + ConfigKey string `json:"config_key"` + SupportsMultiple bool `json:"supports_multiple"` + } `json:"channels"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + foundStatic := false + foundDynamic := false + for _, item := range resp.Channels { + if item.Name == "weixin" { + foundStatic = item.Type == config.ChannelWeixin && item.SupportsMultiple + } + if item.Name == "weixin_work" { + foundDynamic = item.Type == config.ChannelWeixin && + item.ConfigKey == "weixin_work" && + item.SupportsMultiple + } + } + if !foundStatic { + t.Fatal("expected static weixin catalog entry") + } + if !foundDynamic { + t.Fatal("expected dynamic weixin instance in catalog") + } +} + +func TestHandleGetChannelConfig_ReturnsDynamicWeixinInstance(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := &config.Channel{Enabled: true, Type: config.ChannelWeixin} + wxCfg := &config.WeixinSettings{ + AccountID: "work-account", + BaseURL: "https://ilinkai.weixin.qq.com/", + } + wxCfg.SetToken("secret-token") + if err := bc.Decode(wxCfg); err != nil { + t.Fatalf("Decode() error = %v", err) + } + cfg.Channels["weixin_work"] = bc + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/weixin_work/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/channels/weixin_work/config status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp struct { + Config map[string]any `json:"config"` + ConfiguredSecrets []string `json:"configured_secrets"` + ConfigKey string `json:"config_key"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got := resp.ConfigKey; got != "weixin_work" { + t.Fatalf("config_key = %q, want weixin_work", got) + } + if got := resp.Config["account_id"]; got != "work-account" { + t.Fatalf("config.account_id = %#v, want work-account", got) + } + if _, exists := resp.Config["token"]; exists { + t.Fatalf("config should omit token, got %#v", resp.Config["token"]) + } + if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "token" { + t.Fatalf("configured_secrets = %#v, want [token]", resp.ConfiguredSecrets) + } +} diff --git a/web/backend/api/weixin.go b/web/backend/api/weixin.go index 888789f86..17afe93a8 100644 --- a/web/backend/api/weixin.go +++ b/web/backend/api/weixin.go @@ -7,7 +7,9 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net/http" + "regexp" "strings" "time" @@ -35,6 +37,7 @@ const ( type weixinFlow struct { ID string + Channel string Qrcode string // qrcode token from WeChat API (used for status polling) QRDataURI string // base64 PNG data URI for display AccountID string // IlinkBotID returned on confirmed @@ -47,12 +50,19 @@ type weixinFlow struct { type weixinFlowResponse struct { FlowID string `json:"flow_id"` + Channel string `json:"channel,omitempty"` Status string `json:"status"` QRDataURI string `json:"qr_data_uri,omitempty"` AccountID string `json:"account_id,omitempty"` Error string `json:"error,omitempty"` } +type startWeixinFlowRequest struct { + Channel string `json:"channel"` +} + +var weixinChannelNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + // registerWeixinRoutes binds WeChat QR login endpoints to the ServeMux. func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/weixin/flows", h.handleStartWeixinFlow) @@ -63,6 +73,16 @@ func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) { // // POST /api/weixin/flows func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request) { + channelName, err := h.parseWeixinFlowChannel(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := h.validateWeixinChannelTarget(channelName); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() @@ -87,6 +107,7 @@ func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request) now := time.Now() flow := &weixinFlow{ ID: newWeixinFlowID(), + Channel: channelName, Qrcode: qrResp.Qrcode, QRDataURI: dataURI, Status: weixinStatusWait, @@ -101,6 +122,7 @@ func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(weixinFlowResponse{ FlowID: flow.ID, + Channel: flow.Channel, Status: flow.Status, QRDataURI: flow.QRDataURI, }) @@ -128,9 +150,10 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { flow.Status == weixinStatusError { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(weixinFlowResponse{ - FlowID: flow.ID, - Status: flow.Status, - Error: flow.Error, + FlowID: flow.ID, + Channel: flow.Channel, + Status: flow.Status, + Error: flow.Error, }) return } @@ -143,7 +166,12 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { h.setWeixinFlowError(flowID, fmt.Sprintf("client error: %v", err)) flow, _ = h.getWeixinFlow(flowID) w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(weixinFlowResponse{FlowID: flow.ID, Status: flow.Status, Error: flow.Error}) + _ = json.NewEncoder(w).Encode(weixinFlowResponse{ + FlowID: flow.ID, + Channel: flow.Channel, + Status: flow.Status, + Error: flow.Error, + }) return } @@ -153,6 +181,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(weixinFlowResponse{ FlowID: flow.ID, + Channel: flow.Channel, Status: flow.Status, QRDataURI: flow.QRDataURI, }) @@ -171,7 +200,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { h.setWeixinFlowError(flowID, "login confirmed but missing bot_token") break } - if saveErr := h.saveWeixinBinding(statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil { + if saveErr := h.saveWeixinBinding(flow.Channel, statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil { h.setWeixinFlowError(flowID, fmt.Sprintf("failed to save token: %v", saveErr)) logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()}) break @@ -193,6 +222,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") resp := weixinFlowResponse{ FlowID: flow.ID, + Channel: flow.Channel, Status: flow.Status, AccountID: flow.AccountID, Error: flow.Error, @@ -205,17 +235,28 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { // saveWeixinBinding writes the token/account ID, enables the Weixin channel, // and best-effort restarts the gateway when it is currently running. -func (h *Handler) saveWeixinBinding(token, accountID string) error { +func (h *Handler) saveWeixinBinding(channelName, token, accountID string) error { + channelName = strings.TrimSpace(channelName) + if channelName == "" { + channelName = config.ChannelWeixin + } cfg, err := config.LoadConfig(h.configPath) if err != nil { return fmt.Errorf("load config: %w", err) } + if cfg.Channels == nil { + cfg.Channels = config.ChannelsConfig{} + } - bc := cfg.Channels.Get(config.ChannelWeixin) + bc := cfg.Channels.Get(channelName) if bc == nil { bc = &config.Channel{Type: config.ChannelWeixin} - cfg.Channels[config.ChannelWeixin] = bc + cfg.Channels[channelName] = bc } + if strings.TrimSpace(bc.Type) != "" && strings.TrimSpace(bc.Type) != config.ChannelWeixin { + return fmt.Errorf("channel %q already exists with type %q", channelName, bc.Type) + } + bc.Type = config.ChannelWeixin bc.Enabled = true var weixinCfg config.WeixinSettings @@ -267,6 +308,58 @@ func newWeixinFlowID() string { return "wx_" + hex.EncodeToString(buf) } +func (h *Handler) parseWeixinFlowChannel(r *http.Request) (string, error) { + channelName := config.ChannelWeixin + if r.Body == nil || r.ContentLength == 0 { + return channelName, nil + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("failed to read request body") + } + defer r.Body.Close() + if len(strings.TrimSpace(string(body))) == 0 { + return channelName, nil + } + + var req startWeixinFlowRequest + if err := json.Unmarshal(body, &req); err != nil { + return "", fmt.Errorf("invalid JSON: %w", err) + } + if strings.TrimSpace(req.Channel) == "" { + return channelName, nil + } + return strings.TrimSpace(req.Channel), nil +} + +func (h *Handler) validateWeixinChannelTarget(channelName string) error { + channelName = strings.TrimSpace(channelName) + if channelName == "" { + return fmt.Errorf("channel is required") + } + if !weixinChannelNamePattern.MatchString(channelName) { + return fmt.Errorf("invalid channel name %q", channelName) + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if cfg.Channels == nil { + return nil + } + if bc := cfg.Channels.Get(channelName); bc != nil { + typeName := strings.TrimSpace(bc.Type) + if typeName == "" { + typeName = channelName + } + if typeName != config.ChannelWeixin { + return fmt.Errorf("channel %q already exists with type %q", channelName, typeName) + } + } + return nil +} + func (h *Handler) storeWeixinFlow(flow *weixinFlow) { h.weixinMu.Lock() defer h.weixinMu.Unlock() diff --git a/web/backend/api/weixin_test.go b/web/backend/api/weixin_test.go index 575de7b9c..76840447e 100644 --- a/web/backend/api/weixin_test.go +++ b/web/backend/api/weixin_test.go @@ -1,8 +1,11 @@ package api import ( + "bytes" + "encoding/json" "io" "net/http" + "net/http/httptest" "os" "path/filepath" "strconv" @@ -36,7 +39,7 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) { }) h := NewHandler(configPath) - if err := h.saveWeixinBinding("bot-token", "bot-account"); err != nil { + if err := h.saveWeixinBinding("weixin", "bot-token", "bot-account"); err != nil { t.Fatalf("saveWeixinBinding() error = %v, want nil after config save succeeds", err) } @@ -60,3 +63,120 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) { t.Fatalf("Weixin.Enabled = false, want true") } } + +func TestSaveWeixinBindingSavesNamedChannel(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + if err := h.saveWeixinBinding("weixin_work", "bot-token", "bot-account"); err != nil { + t.Fatalf("saveWeixinBinding() error = %v", err) + } + + savedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := savedCfg.Channels["weixin_work"] + if bc == nil { + t.Fatal("expected weixin_work channel to be created") + } + if bc.Type != config.ChannelWeixin { + t.Fatalf("channel type = %q, want %q", bc.Type, config.ChannelWeixin) + } + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + wxCfg := decoded.(*config.WeixinSettings) + if got := wxCfg.Token.String(); got != "bot-token" { + t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token") + } + if got := wxCfg.AccountID; got != "bot-account" { + t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account") + } +} + +func TestHandleStartWeixinFlowRejectsInvalidChannel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest( + http.MethodPost, + "/api/weixin/flows", + bytes.NewBufferString(`{"channel":"bad name"}`), + ) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestHandleStartWeixinFlowRejectsConflictingChannelType(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Channels["weixin_conflict"] = &config.Channel{Enabled: true, Type: config.ChannelTelegram} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest( + http.MethodPost, + "/api/weixin/flows", + bytes.NewBufferString(`{"channel":"weixin_conflict"}`), + ) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestParseWeixinFlowChannelDefaultsToWeixin(t *testing.T) { + h := &Handler{} + + req := httptest.NewRequest(http.MethodPost, "/api/weixin/flows", nil) + channel, err := h.parseWeixinFlowChannel(req) + if err != nil { + t.Fatalf("parseWeixinFlowChannel() error = %v", err) + } + if channel != config.ChannelWeixin { + t.Fatalf("channel = %q, want %q", channel, config.ChannelWeixin) + } +} + +func TestParseWeixinFlowChannelReadsBody(t *testing.T) { + h := &Handler{} + + body, err := json.Marshal(map[string]string{"channel": "weixin_work"}) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/weixin/flows", bytes.NewReader(body)) + channel, err := h.parseWeixinFlowChannel(req) + if err != nil { + t.Fatalf("parseWeixinFlowChannel() error = %v", err) + } + if channel != "weixin_work" { + t.Fatalf("channel = %q, want weixin_work", channel) + } +} diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index 42a3a0606..9134670cf 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -5,9 +5,12 @@ export type AppConfig = Record export interface SupportedChannel { name: string + type?: string display_name?: string config_key: string variant?: string + template?: boolean + supports_multiple?: boolean } export interface ChannelConfigResponse { @@ -82,6 +85,7 @@ export async function patchAppConfig( export interface WeixinFlowResponse { flow_id: string status: "wait" | "scaned" | "confirmed" | "expired" | "error" + channel?: string qr_data_uri?: string account_id?: string error?: string @@ -95,8 +99,15 @@ export interface WecomFlowResponse { error?: string } -export async function startWeixinFlow(): Promise { - return request("/api/weixin/flows", { method: "POST" }) +export async function startWeixinFlow( + channel?: string, +): Promise { + const body = channel ? JSON.stringify({ channel }) : undefined + return request("/api/weixin/flows", { + method: "POST", + headers: body ? { "Content-Type": "application/json" } : undefined, + body, + }) } export async function pollWeixinFlow( diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index a235daf8d..0d6ee422a 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -1,4 +1,5 @@ import { IconLoader2 } from "@tabler/icons-react" +import { useNavigate } from "@tanstack/react-router" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -25,6 +26,7 @@ import { WecomForm } from "@/components/channels/channel-forms/wecom-form" import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { useGateway } from "@/hooks/use-gateway" import { refreshGatewayState } from "@/store/gateway" @@ -61,21 +63,26 @@ function normalizeConfig( rawConfig: ChannelConfig, ): ChannelConfig { const config = { ...rawConfig } - if (channel.name === "whatsapp_native") { + if (getChannelType(channel) === "whatsapp_native") { config.use_native = true } - if (channel.name === "whatsapp") { + if (getChannelType(channel) === "whatsapp") { config.use_native = false } return config } +function getChannelType(channel: SupportedChannel): string { + return channel.type ?? channel.name +} + function buildSavePayload( channel: SupportedChannel, editConfig: ChannelConfig, enabled: boolean, ): ChannelConfig { - const payload: ChannelConfig = { enabled, type: channel.config_key } + const channelType = getChannelType(channel) + const payload: ChannelConfig = { enabled, type: channelType } const settings: ChannelConfig = {} for (const [key, value] of Object.entries(editConfig)) { @@ -102,10 +109,10 @@ function buildSavePayload( } } - if (channel.name === "whatsapp_native") { + if (channelType === "whatsapp_native") { settings.use_native = true } - if (channel.name === "whatsapp") { + if (channelType === "whatsapp") { settings.use_native = false } @@ -121,12 +128,13 @@ function isConfigured( config: ChannelConfig, configuredSecrets: readonly string[], ): boolean { + const channelType = getChannelType(channel) const hasValue = (key: string) => !isMissingRequiredValue( getFieldValueForValidation(config, configuredSecrets, key), ) - switch (channel.name) { + switch (channelType) { case "telegram": return hasValue("token") case "discord": @@ -203,6 +211,29 @@ function getRequiredFieldKeys(channelName: string): string[] { } } +function buildSuggestedWeixinChannelName(existingNames: readonly string[]): string { + const used = new Set(existingNames) + if (!used.has("weixin_2")) { + return "weixin_2" + } + let index = 3 + for (;;) { + const candidate = `weixin_${index}` + if (!used.has(candidate)) { + return candidate + } + index++ + } +} + +function isValidChannelName(name: string): boolean { + return /^[A-Za-z0-9_.-]+$/.test(name) +} + +function notifyChannelsUpdated() { + window.dispatchEvent(new CustomEvent("picoclaw:channels-updated")) +} + function isMissingRequiredValue(value: unknown): boolean { if (value === null || value === undefined) { return true @@ -232,24 +263,30 @@ const CHANNELS_WITHOUT_DOCS = new Set([ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const { t, i18n } = useTranslation() const { state: gatewayState } = useGateway() + const navigate = useNavigate() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [creatingChannel, setCreatingChannel] = useState(false) const [fetchError, setFetchError] = useState("") const [serverError, setServerError] = useState("") + const [createChannelError, setCreateChannelError] = useState("") const [fieldErrors, setFieldErrors] = useState>({}) + const [catalogChannels, setCatalogChannels] = useState([]) const [channel, setChannel] = useState(null) const [baseConfig, setBaseConfig] = useState({}) const [editConfig, setEditConfig] = useState({}) const [configuredSecrets, setConfiguredSecrets] = useState([]) const [enabled, setEnabled] = useState(false) + const [newWeixinChannelName, setNewWeixinChannelName] = useState("") const loadData = useCallback( async (silent = false) => { if (!silent) setLoading(true) try { const catalog = await getChannelsCatalog() + setCatalogChannels(catalog.channels) const matched = catalog.channels.find((item) => item.name === channelName) ?? null @@ -270,16 +307,25 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const channelConfig = await getChannelConfig(channelName) const raw = asRecord(channelConfig.config) const normalized = normalizeConfig(matched, raw) + const currentType = getChannelType(matched) setChannel(matched) setBaseConfig(normalized) - setEditConfig(buildEditConfig(matched.name, normalized)) + setEditConfig(buildEditConfig(currentType, normalized)) setConfiguredSecrets(channelConfig.configured_secrets ?? []) setEnabled(asBool(normalized.enabled)) setFetchError("") setServerError("") + setCreateChannelError("") setFieldErrors({}) + setNewWeixinChannelName((prev) => { + const next = buildSuggestedWeixinChannelName( + catalog.channels.map((item) => item.name), + ) + return prev.trim() === "" ? next : prev + }) } catch (e) { + setCatalogChannels([]) setConfiguredSecrets([]) setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { @@ -314,7 +360,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const docsUrl = useMemo(() => { if (!channel) return "" - if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" + const channelType = getChannelType(channel) + if (CHANNELS_WITHOUT_DOCS.has(channelType)) return "" const language = ( i18n.resolvedLanguage ?? i18n.language ?? @@ -323,7 +370,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const base = language.startsWith("zh") ? "https://docs.picoclaw.io/zh-Hans/docs/channels" : "https://docs.picoclaw.io/docs/channels" - return `${base}/${getChannelDocSlug(channel.name)}` + return `${base}/${getChannelDocSlug(channelType)}` }, [channel, i18n.language, i18n.resolvedLanguage]) const channelDisplayName = useMemo(() => { @@ -331,21 +378,24 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return getChannelDisplayName(channel, t) }, [channel, channelName, t]) - const hidesPageLevelEnableToggle = channel?.name === "wecom" + const channelType = channel ? getChannelType(channel) : channelName + const hidesPageLevelEnableToggle = channelType === "wecom" + const supportsMultiple = channel?.supports_multiple === true const hiddenKeys = useMemo(() => { if (!channel) return [] - if (channel.name === "whatsapp") { + const currentType = getChannelType(channel) + if (currentType === "whatsapp") { return ["use_native"] } - if (channel.name === "whatsapp_native") { + if (currentType === "whatsapp_native") { return ["use_native", "bridge_url"] } return [] }, [channel]) const requiredKeys = useMemo( - () => getRequiredFieldKeys(channelName), - [channelName], + () => getRequiredFieldKeys(channelType), + [channelType], ) const handleChange = useCallback((key: string, value: unknown) => { @@ -364,7 +414,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const handleReset = () => { if (!channel) return - setEditConfig(buildEditConfig(channel.name, baseConfig)) + setEditConfig(buildEditConfig(getChannelType(channel), baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) @@ -398,6 +448,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { [channel.config_key]: savePayload, }, }) + notifyChannelsUpdated() await loadData() } catch (e) { const message = @@ -411,6 +462,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const handleWeixinBindSuccess = useCallback(async () => { try { setEnabled(true) + notifyChannelsUpdated() await Promise.all([loadData(true), refreshGatewayState({ force: true })]) } catch (e) { const message = @@ -423,6 +475,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const handleWecomBindSuccess = useCallback(async () => { try { setEnabled(true) + notifyChannelsUpdated() await Promise.all([loadData(true), refreshGatewayState({ force: true })]) } catch (e) { const message = @@ -450,11 +503,54 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { [loadData, t], ) + const handleCreateWeixinChannel = useCallback(async () => { + const nextChannelName = newWeixinChannelName.trim() + if (nextChannelName === "") { + setCreateChannelError(t("channels.weixin.channelNameRequired")) + return + } + if (!isValidChannelName(nextChannelName)) { + setCreateChannelError(t("channels.weixin.invalidChannelName")) + return + } + if (catalogChannels.some((item) => item.name === nextChannelName)) { + setCreateChannelError( + t("channels.weixin.duplicateChannel", { name: nextChannelName }), + ) + return + } + + setCreatingChannel(true) + setCreateChannelError("") + try { + await patchAppConfig({ + channel_list: { + [nextChannelName]: { + enabled: false, + type: "weixin", + }, + }, + }) + notifyChannelsUpdated() + await navigate({ + to: "/channels/$name", + params: { name: nextChannelName }, + }) + } catch (e) { + setCreateChannelError( + e instanceof Error ? e.message : t("channels.page.saveError"), + ) + } finally { + setCreatingChannel(false) + } + }, [catalogChannels, navigate, newWeixinChannelName, t]) + const renderForm = () => { if (!channel) return null const isEdit = configured + const currentType = getChannelType(channel) - switch (channel.name) { + switch (currentType) { case "telegram": return ( )} + {channelType === "weixin" && supportsMultiple && ( +
+
+

+ {t("channels.weixin.addChannelTitle")} +

+

+ {t("channels.weixin.addChannelDesc")} +

+
+
+
+ + { + setNewWeixinChannelName(e.target.value) + setCreateChannelError("") + }} + placeholder="weixin_2" + /> +
+ +
+ {createChannelError && ( +

{createChannelError}

+ )} +
+ )} + {renderForm()} {serverError && ( diff --git a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx index ec80520ea..8a7f3f923 100644 --- a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx @@ -8,7 +8,7 @@ import { import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import type { ChannelConfig } from "@/api/channels" +import type { ChannelConfig, SupportedChannel } from "@/api/channels" import { pollWeixinFlow, startWeixinFlow } from "@/api/channels" import { Field } from "@/components/shared-form" import { Button } from "@/components/ui/button" @@ -31,6 +31,7 @@ type BindingState = | "error" interface WeixinFormProps { + channel: SupportedChannel config: ChannelConfig onChange: (key: string, value: unknown) => void isEdit: boolean @@ -47,6 +48,7 @@ function asStringArray(value: unknown): string[] { } export function WeixinForm({ + channel, config, onChange, isEdit, @@ -126,7 +128,7 @@ export function WeixinForm({ setQrDataURI(null) stopPolling() try { - const resp = await startWeixinFlow() + const resp = await startWeixinFlow(channel.config_key) setQrDataURI(resp.qr_data_uri ?? null) setBindState("waiting") startPolling(resp.flow_id) diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts index 22fc24e57..73879fc94 100644 --- a/web/frontend/src/hooks/use-sidebar-channels.ts +++ b/web/frontend/src/hooks/use-sidebar-channels.ts @@ -112,11 +112,16 @@ function isChannelEnabled( return true } +function getChannelType(channel: SupportedChannel): string { + return channel.type ?? channel.name +} + function buildChannelEnabledMap( channels: SupportedChannel[], appConfig: AppConfig, ): Record { - const channelsConfig = asRecord(asRecord(appConfig).channels) + const rootConfig = asRecord(appConfig) + const channelsConfig = asRecord(rootConfig.channel_list ?? rootConfig.channels) const result: Record = {} for (const channel of channels) { result[channel.name] = isChannelEnabled(channel, channelsConfig) @@ -168,8 +173,16 @@ export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) { React.useEffect(() => { let active = true reloadChannels(() => active) + const handleChannelsUpdated = () => { + reloadChannels(() => active) + } + window.addEventListener("picoclaw:channels-updated", handleChannelsUpdated) return () => { active = false + window.removeEventListener( + "picoclaw:channels-updated", + handleChannelsUpdated, + ) } }, [reloadChannels]) @@ -198,9 +211,9 @@ export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) { } const aImportance = - channelImportanceIndex.get(a.name) ?? Number.MAX_SAFE_INTEGER + channelImportanceIndex.get(getChannelType(a)) ?? Number.MAX_SAFE_INTEGER const bImportance = - channelImportanceIndex.get(b.name) ?? Number.MAX_SAFE_INTEGER + channelImportanceIndex.get(getChannelType(b)) ?? Number.MAX_SAFE_INTEGER if (aImportance !== bImportance) { return aImportance - bImportance } @@ -223,7 +236,7 @@ export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) { key: channel.name, title: getChannelDisplayName(channel, t), url: `/channels/${channel.name}`, - icon: CHANNEL_ICON_MAP[channel.name] ?? IconPlug, + icon: CHANNEL_ICON_MAP[getChannelType(channel)] ?? IconPlug, })), [t, visibleChannels], ) diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 7a5c58b30..2c2bb3393 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -310,10 +310,17 @@ "weixin": { "bindTitle": "WeChat Account Binding", "bindDesc": "Scan the QR code with WeChat to bind your personal account.", + "addChannelTitle": "Add WeChat Channel", + "addChannelDesc": "Create another WeChat channel instance before binding a different account.", "bind": "Bind WeChat", + "createChannel": "Create Channel", "rebind": "Re-bind", "bound": "WeChat Bound", "notBound": "WeChat account not bound yet.", + "channelName": "Channel Name", + "channelNameRequired": "Channel name is required.", + "duplicateChannel": "Channel \"{{name}}\" already exists.", + "invalidChannelName": "Channel name may contain only letters, numbers, dots, underscores, and hyphens.", "generating": "Generating QR code...", "scanHint": "Open WeChat and scan the QR code", "scanned": "Scanned — please confirm in WeChat", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index aaebfa625..0061f7e10 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -310,10 +310,17 @@ "weixin": { "bindTitle": "微信账号绑定", "bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。", + "addChannelTitle": "新增微信通道", + "addChannelDesc": "先创建一个新的微信通道实例,再绑定另一套微信账号。", "bind": "绑定微信", + "createChannel": "创建通道", "rebind": "重新绑定", "bound": "微信已绑定", "notBound": "尚未绑定微信账号。", + "channelName": "通道名", + "channelNameRequired": "请输入通道名。", + "duplicateChannel": "通道“{{name}}”已存在。", + "invalidChannelName": "通道名只能包含字母、数字、点、下划线和连字符。", "generating": "正在生成二维码...", "scanHint": "打开微信,扫描二维码", "scanned": "已扫码 — 请在微信中确认",