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.
This commit is contained in:
Ethan Wang 2026-04-20 13:04:13 +00:00
parent e556a816e4
commit c4c259c748
18 changed files with 1011 additions and 79 deletions

View file

@ -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)

View file

@ -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")
}

View file

@ -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.

View file

@ -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 分别绑定
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。

View file

@ -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

View file

@ -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"

View file

@ -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,

View file

@ -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),

View file

@ -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

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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)
}
}

View file

@ -5,9 +5,12 @@ export type AppConfig = Record<string, unknown>
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<WeixinFlowResponse> {
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
export async function startWeixinFlow(
channel?: string,
): Promise<WeixinFlowResponse> {
const body = channel ? JSON.stringify({ channel }) : undefined
return request<WeixinFlowResponse>("/api/weixin/flows", {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body,
})
}
export async function pollWeixinFlow(

View file

@ -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<Record<string, string>>({})
const [catalogChannels, setCatalogChannels] = useState<SupportedChannel[]>([])
const [channel, setChannel] = useState<SupportedChannel | null>(null)
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
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 (
<TelegramForm
@ -494,6 +590,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
case "weixin":
return (
<WeixinForm
channel={channel}
config={editConfig}
onChange={handleChange}
isEdit={isEdit}
@ -574,6 +671,50 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
</div>
)}
{channelType === "weixin" && supportsMultiple && (
<div className="bg-card text-card-foreground border-border/60 space-y-4 rounded-xl border px-6 py-5 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium">
{t("channels.weixin.addChannelTitle")}
</p>
<p className="text-muted-foreground text-sm">
{t("channels.weixin.addChannelDesc")}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
htmlFor="new-weixin-channel-name"
className="mb-1 block text-sm font-medium"
>
{t("channels.weixin.channelName")}
</label>
<Input
id="new-weixin-channel-name"
value={newWeixinChannelName}
onChange={(e) => {
setNewWeixinChannelName(e.target.value)
setCreateChannelError("")
}}
placeholder="weixin_2"
/>
</div>
<Button
type="button"
onClick={() => void handleCreateWeixinChannel()}
disabled={creatingChannel}
>
{creatingChannel
? t("common.saving")
: t("channels.weixin.createChannel")}
</Button>
</div>
{createChannelError && (
<p className="text-destructive text-sm">{createChannelError}</p>
)}
</div>
)}
{renderForm()}
{serverError && (

View file

@ -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)

View file

@ -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<string, boolean> {
const channelsConfig = asRecord(asRecord(appConfig).channels)
const rootConfig = asRecord(appConfig)
const channelsConfig = asRecord(rootConfig.channel_list ?? rootConfig.channels)
const result: Record<string, boolean> = {}
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],
)

View file

@ -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",

View file

@ -310,10 +310,17 @@
"weixin": {
"bindTitle": "微信账号绑定",
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
"addChannelTitle": "新增微信通道",
"addChannelDesc": "先创建一个新的微信通道实例,再绑定另一套微信账号。",
"bind": "绑定微信",
"createChannel": "创建通道",
"rebind": "重新绑定",
"bound": "微信已绑定",
"notBound": "尚未绑定微信账号。",
"channelName": "通道名",
"channelNameRequired": "请输入通道名。",
"duplicateChannel": "通道“{{name}}”已存在。",
"invalidChannelName": "通道名只能包含字母、数字、点、下划线和连字符。",
"generating": "正在生成二维码...",
"scanHint": "打开微信,扫描二维码",
"scanned": "已扫码 — 请在微信中确认",