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 ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -14,6 +15,7 @@ import (
func newWeixinCommand() *cobra.Command { func newWeixinCommand() *cobra.Command {
var baseURL string var baseURL string
var channelName string
var proxy string var proxy string
var timeout int var timeout int
@ -29,18 +31,19 @@ config so you can start the gateway immediately.
Example: Example:
picoclaw auth weixin`, picoclaw auth weixin`,
RunE: func(cmd *cobra.Command, _ []string) error { 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(&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().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds") cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
return cmd 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("Starting Weixin (WeChat personal) login...")
fmt.Println() fmt.Println()
@ -70,9 +73,9 @@ func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
effectiveBaseURL = baseURL 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) fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
printManualWeixinConfig(botToken, effectiveBaseURL) printManualWeixinConfig(channelName, botToken, accountID, effectiveBaseURL)
return nil return nil
} }
@ -81,32 +84,49 @@ func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
fmt.Println(" picoclaw gateway") fmt.Println(" picoclaw gateway")
fmt.Println() fmt.Println()
fmt.Println("To restrict which WeChat users can send messages, add their user IDs") 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 return nil
} }
// saveWeixinConfig patches channels.weixin in the config and saves it. func normalizeWeixinChannelName(name string) string {
func saveWeixinConfig(token, baseURL, proxy string) error { 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() cfgPath := internal.GetConfigPath()
channelName = normalizeWeixinChannelName(channelName)
cfg, err := config.LoadConfig(cfgPath) cfg, err := config.LoadConfig(cfgPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to load config: %w", err) 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 { if bc == nil {
bc = &config.Channel{Type: config.ChannelWeixin} 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 bc.Enabled = true
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if weixinCfg, ok := decoded.(*config.WeixinSettings); ok { if weixinCfg, ok := decoded.(*config.WeixinSettings); ok {
weixinCfg.Token = *config.NewSecureString(token) weixinCfg.Token = *config.NewSecureString(token)
const defaultBase = "https://ilinkai.weixin.qq.com/" weixinCfg.AccountID = accountID
if baseURL != "" && baseURL != defaultBase { if baseURL != "" {
weixinCfg.BaseURL = baseURL weixinCfg.BaseURL = baseURL
} }
if proxy != "" { if proxy != "" {
@ -118,13 +138,18 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
return config.SaveConfig(cfgPath, cfg) return config.SaveConfig(cfgPath, cfg)
} }
func printManualWeixinConfig(token, baseURL string) { func printManualWeixinConfig(channelName, token, accountID, baseURL string) {
channelName = normalizeWeixinChannelName(channelName)
fmt.Println() fmt.Println()
fmt.Println("Add the following to the channels section of your picoclaw config:") fmt.Println("Add the following to the channels section of your picoclaw config:")
fmt.Println() fmt.Println()
fmt.Println(` "weixin": {`) fmt.Printf(" %q: {\n", channelName)
fmt.Println(` "enabled": true,`) fmt.Println(` "enabled": true,`)
fmt.Println(` "type": "weixin",`)
fmt.Printf(" \"token\": %q,\n", token) fmt.Printf(" \"token\": %q,\n", token)
if accountID != "" {
fmt.Printf(" \"account_id\": %q,\n", accountID)
}
const defaultBase = "https://ilinkai.weixin.qq.com/" const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase { if baseURL != "" && baseURL != defaultBase {
fmt.Printf(" \"base_url\": %q,\n", baseURL) 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 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 ## ⚙️ 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 ### Configuration Fields
| Field | Description | | Field | Description |
|---|---| |---|---|
| `enabled` | Set to `true` to enable the channel at startup. | | `enabled` | Set to `true` to enable the channel at startup. |
| `token` | The authentication token obtained via QR login. | | `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. | | `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. | | `proxy` | (Optional) HTTP proxy address (e.g. `http://localhost:7890`) for environments where connection to `ilinkai.weixin.qq.com` is restricted. |
## ⚠️ Important Notes ## ⚠️ 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. - **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 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` 以在启动时激活该频道。 | | `enabled` | 设置为 `true` 以在启动时激活该频道。 |
| `token` | 通过扫码获取的认证令牌。 | | `token` | 通过扫码获取的认证令牌。 |
| `account_id` | (可选) 扫码返回的稳定账号标识,用于保持账号级状态文件路径稳定。 |
| `allow_from` | (可选) 允许与机器人交互的微信 User ID 列表。如果为空,任何能给此微信号发消息的人都可以触发机器人。 | | `allow_from` | (可选) 允许与机器人交互的微信 User ID 列表。如果为空,任何能给此微信号发消息的人都可以触发机器人。 |
| `proxy` | (可选) HTTP 代理地址(例如 `http://localhost:7890`),适合网络访问受限环境。 | | `proxy` | (可选) HTTP 代理地址(例如 `http://localhost:7890`),适合网络访问受限环境。 |
## ⚠️ 注意事项 ## ⚠️ 注意事项
- **单端绑定**: iLink 令牌通常与单个会话绑定。在其他地方重新扫码激活可能会导致旧令牌失效。 - **一个账号一个 token**: iLink 令牌通常与单个微信账号会话绑定。多个微信账号请作为多个 channel 分别绑定
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。 - **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。

View file

@ -383,7 +383,7 @@ func (c *WeixinChannel) storeInboundBytes(
ContentType: contentType, ContentType: contentType,
Source: "weixin", Source: "weixin",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, basechannels.BuildMediaScope("weixin", chatID, messageID)) }, basechannels.BuildMediaScope(c.channelName(), chatID, messageID))
if err != nil { if err != nil {
os.Remove(tmpPath) os.Remove(tmpPath)
return "", err return "", err

View file

@ -44,7 +44,33 @@ func picoclawHomeDir() string {
return config.GetHome() 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 { 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()) token := strings.TrimSpace(cfg.Token.String())
if token == "" { if token == "" {
return "default" return "default"

View file

@ -97,7 +97,10 @@ func (c *WeixinChannel) Start(ctx context.Context) error {
c.SetRunning(true) c.SetRunning(true)
c.restoreContextTokens() c.restoreContextTokens()
go c.pollLoop(c.ctx) 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 return nil
} }
@ -151,6 +154,26 @@ func (c *WeixinChannel) Stop(ctx context.Context) error {
return nil 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. // pollLoop is the long-poll receive loop. It runs until ctx is canceled.
func (c *WeixinChannel) pollLoop(ctx context.Context) { func (c *WeixinChannel) pollLoop(ctx context.Context) {
const ( const (
@ -358,6 +381,7 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
} }
metadata := map[string]string{ metadata := map[string]string{
"account_id": c.accountID(),
"from_user_id": fromUserID, "from_user_id": fromUserID,
"context_token": msg.ContextToken, "context_token": msg.ContextToken,
"session_id": msg.SessionID, "session_id": msg.SessionID,
@ -376,7 +400,8 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
} }
inboundCtx := bus.InboundContext{ inboundCtx := bus.InboundContext{
Channel: "weixin", Channel: c.channelName(),
Account: c.accountID(),
ChatID: fromUserID, ChatID: fromUserID,
ChatType: "direct", ChatType: "direct",
SenderID: fromUserID, SenderID: fromUserID,

View file

@ -8,11 +8,14 @@ import (
"io" "io"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus"
basechannels "github.com/sipeed/picoclaw/pkg/channels" basechannels "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
) )
type roundTripFunc func(*http.Request) (*http.Response, error) 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) { func TestSessionPauseGuard(t *testing.T) {
ch := &WeixinChannel{ ch := &WeixinChannel{
typingCache: make(map[string]typingTicketCacheEntry), typingCache: make(map[string]typingTicketCacheEntry),

View file

@ -3,33 +3,39 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
type channelCatalogItem struct { type channelCatalogItem struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type,omitempty"`
DisplayName string `json:"display_name,omitempty"`
ConfigKey string `json:"config_key"` ConfigKey string `json:"config_key"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty"`
Template bool `json:"template,omitempty"`
SupportsMultiple bool `json:"supports_multiple,omitempty"`
} }
var channelCatalog = []channelCatalogItem{ var channelCatalog = []channelCatalogItem{
{Name: "weixin", ConfigKey: "weixin"}, {Name: "weixin", Type: "weixin", ConfigKey: "weixin", SupportsMultiple: true},
{Name: "telegram", ConfigKey: "telegram"}, {Name: "telegram", Type: "telegram", ConfigKey: "telegram"},
{Name: "discord", ConfigKey: "discord"}, {Name: "discord", Type: "discord", ConfigKey: "discord"},
{Name: "slack", ConfigKey: "slack"}, {Name: "slack", Type: "slack", ConfigKey: "slack"},
{Name: "feishu", ConfigKey: "feishu"}, {Name: "feishu", Type: "feishu", ConfigKey: "feishu"},
{Name: "dingtalk", ConfigKey: "dingtalk"}, {Name: "dingtalk", Type: "dingtalk", ConfigKey: "dingtalk"},
{Name: "line", ConfigKey: "line"}, {Name: "line", Type: "line", ConfigKey: "line"},
{Name: "qq", ConfigKey: "qq"}, {Name: "qq", Type: "qq", ConfigKey: "qq"},
{Name: "onebot", ConfigKey: "onebot"}, {Name: "onebot", Type: "onebot", ConfigKey: "onebot"},
{Name: "wecom", ConfigKey: "wecom"}, {Name: "wecom", Type: "wecom", ConfigKey: "wecom"},
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, {Name: "whatsapp", Type: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, {Name: "whatsapp_native", Type: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
{Name: "pico", ConfigKey: "pico"}, {Name: "pico", Type: "pico", ConfigKey: "pico"},
{Name: "maixcam", ConfigKey: "maixcam"}, {Name: "maixcam", Type: "maixcam", ConfigKey: "maixcam"},
{Name: "matrix", ConfigKey: "matrix"}, {Name: "matrix", Type: "matrix", ConfigKey: "matrix"},
{Name: "irc", ConfigKey: "irc"}, {Name: "irc", Type: "irc", ConfigKey: "irc"},
} }
type channelConfigResponse struct { type channelConfigResponse struct {
@ -49,9 +55,14 @@ func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
// //
// GET /api/channels/catalog // GET /api/channels/catalog
func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) { 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ 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 // GET /api/channels/{name}/config
func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
channelName := r.PathValue("name") 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) cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
@ -72,6 +78,12 @@ func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request)
return return
} }
item, ok := findChannelCatalogItem(buildChannelCatalog(cfg), channelName)
if !ok {
http.Error(w, "Channel not found", http.StatusNotFound)
return
}
resp := buildChannelConfigResponse(cfg, item) resp := buildChannelConfigResponse(cfg, item)
w.Header().Set("Content-Type", "application/json") 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) { func findChannelCatalogItem(items []channelCatalogItem, name string) (channelCatalogItem, bool) {
for _, item := range channelCatalog { for _, item := range items {
if item.Name == name { if item.Name == name {
return item, true return item, true
} }
@ -89,6 +101,55 @@ func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
return channelCatalogItem{}, false 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{ var channelSecretFieldMap = map[string][]string{
"weixin": {"token"}, "weixin": {"token"},
"telegram": {"token"}, "telegram": {"token"},
@ -118,6 +179,9 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha
bc := cfg.Channels.Get(item.ConfigKey) bc := cfg.Channels.Get(item.ConfigKey)
if bc == nil { if bc == nil {
bc = defaultChannelConfig(item.ConfigKey) bc = defaultChannelConfig(item.ConfigKey)
if bc == nil && item.Type != "" && item.Type != item.ConfigKey {
bc = defaultChannelConfig(item.Type)
}
if bc == nil { if bc == nil {
resp.Config = map[string]any{} resp.Config = map[string]any{}
return resp return resp
@ -125,7 +189,11 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha
} }
// Detect configured secrets by checking the raw Settings JSON // 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 resp.ConfiguredSecrets = secrets
// Parse settings into a generic map for JSON response // 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) 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/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@ -35,6 +37,7 @@ const (
type weixinFlow struct { type weixinFlow struct {
ID string ID string
Channel string
Qrcode string // qrcode token from WeChat API (used for status polling) Qrcode string // qrcode token from WeChat API (used for status polling)
QRDataURI string // base64 PNG data URI for display QRDataURI string // base64 PNG data URI for display
AccountID string // IlinkBotID returned on confirmed AccountID string // IlinkBotID returned on confirmed
@ -47,12 +50,19 @@ type weixinFlow struct {
type weixinFlowResponse struct { type weixinFlowResponse struct {
FlowID string `json:"flow_id"` FlowID string `json:"flow_id"`
Channel string `json:"channel,omitempty"`
Status string `json:"status"` Status string `json:"status"`
QRDataURI string `json:"qr_data_uri,omitempty"` QRDataURI string `json:"qr_data_uri,omitempty"`
AccountID string `json:"account_id,omitempty"` AccountID string `json:"account_id,omitempty"`
Error string `json:"error,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. // registerWeixinRoutes binds WeChat QR login endpoints to the ServeMux.
func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) { func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/weixin/flows", h.handleStartWeixinFlow) mux.HandleFunc("POST /api/weixin/flows", h.handleStartWeixinFlow)
@ -63,6 +73,16 @@ func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) {
// //
// POST /api/weixin/flows // POST /api/weixin/flows
func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request) { 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) ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel() defer cancel()
@ -87,6 +107,7 @@ func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request)
now := time.Now() now := time.Now()
flow := &weixinFlow{ flow := &weixinFlow{
ID: newWeixinFlowID(), ID: newWeixinFlowID(),
Channel: channelName,
Qrcode: qrResp.Qrcode, Qrcode: qrResp.Qrcode,
QRDataURI: dataURI, QRDataURI: dataURI,
Status: weixinStatusWait, Status: weixinStatusWait,
@ -101,6 +122,7 @@ func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(weixinFlowResponse{ _ = json.NewEncoder(w).Encode(weixinFlowResponse{
FlowID: flow.ID, FlowID: flow.ID,
Channel: flow.Channel,
Status: flow.Status, Status: flow.Status,
QRDataURI: flow.QRDataURI, QRDataURI: flow.QRDataURI,
}) })
@ -129,6 +151,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(weixinFlowResponse{ _ = json.NewEncoder(w).Encode(weixinFlowResponse{
FlowID: flow.ID, FlowID: flow.ID,
Channel: flow.Channel,
Status: flow.Status, Status: flow.Status,
Error: flow.Error, Error: flow.Error,
}) })
@ -143,7 +166,12 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
h.setWeixinFlowError(flowID, fmt.Sprintf("client error: %v", err)) h.setWeixinFlowError(flowID, fmt.Sprintf("client error: %v", err))
flow, _ = h.getWeixinFlow(flowID) flow, _ = h.getWeixinFlow(flowID)
w.Header().Set("Content-Type", "application/json") 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 return
} }
@ -153,6 +181,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(weixinFlowResponse{ _ = json.NewEncoder(w).Encode(weixinFlowResponse{
FlowID: flow.ID, FlowID: flow.ID,
Channel: flow.Channel,
Status: flow.Status, Status: flow.Status,
QRDataURI: flow.QRDataURI, 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") h.setWeixinFlowError(flowID, "login confirmed but missing bot_token")
break 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)) h.setWeixinFlowError(flowID, fmt.Sprintf("failed to save token: %v", saveErr))
logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()}) logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()})
break break
@ -193,6 +222,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
resp := weixinFlowResponse{ resp := weixinFlowResponse{
FlowID: flow.ID, FlowID: flow.ID,
Channel: flow.Channel,
Status: flow.Status, Status: flow.Status,
AccountID: flow.AccountID, AccountID: flow.AccountID,
Error: flow.Error, 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, // saveWeixinBinding writes the token/account ID, enables the Weixin channel,
// and best-effort restarts the gateway when it is currently running. // 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) cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
return fmt.Errorf("load config: %w", err) 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 { if bc == nil {
bc = &config.Channel{Type: config.ChannelWeixin} 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 bc.Enabled = true
var weixinCfg config.WeixinSettings var weixinCfg config.WeixinSettings
@ -267,6 +308,58 @@ func newWeixinFlowID() string {
return "wx_" + hex.EncodeToString(buf) 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) { func (h *Handler) storeWeixinFlow(flow *weixinFlow) {
h.weixinMu.Lock() h.weixinMu.Lock()
defer h.weixinMu.Unlock() defer h.weixinMu.Unlock()

View file

@ -1,8 +1,11 @@
package api package api
import ( import (
"bytes"
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -36,7 +39,7 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) {
}) })
h := NewHandler(configPath) 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) 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") 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 { export interface SupportedChannel {
name: string name: string
type?: string
display_name?: string display_name?: string
config_key: string config_key: string
variant?: string variant?: string
template?: boolean
supports_multiple?: boolean
} }
export interface ChannelConfigResponse { export interface ChannelConfigResponse {
@ -82,6 +85,7 @@ export async function patchAppConfig(
export interface WeixinFlowResponse { export interface WeixinFlowResponse {
flow_id: string flow_id: string
status: "wait" | "scaned" | "confirmed" | "expired" | "error" status: "wait" | "scaned" | "confirmed" | "expired" | "error"
channel?: string
qr_data_uri?: string qr_data_uri?: string
account_id?: string account_id?: string
error?: string error?: string
@ -95,8 +99,15 @@ export interface WecomFlowResponse {
error?: string error?: string
} }
export async function startWeixinFlow(): Promise<WeixinFlowResponse> { export async function startWeixinFlow(
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" }) 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( export async function pollWeixinFlow(

View file

@ -1,4 +1,5 @@
import { IconLoader2 } from "@tabler/icons-react" import { IconLoader2 } from "@tabler/icons-react"
import { useNavigate } from "@tanstack/react-router"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next" 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 { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useGateway } from "@/hooks/use-gateway" import { useGateway } from "@/hooks/use-gateway"
import { refreshGatewayState } from "@/store/gateway" import { refreshGatewayState } from "@/store/gateway"
@ -61,21 +63,26 @@ function normalizeConfig(
rawConfig: ChannelConfig, rawConfig: ChannelConfig,
): ChannelConfig { ): ChannelConfig {
const config = { ...rawConfig } const config = { ...rawConfig }
if (channel.name === "whatsapp_native") { if (getChannelType(channel) === "whatsapp_native") {
config.use_native = true config.use_native = true
} }
if (channel.name === "whatsapp") { if (getChannelType(channel) === "whatsapp") {
config.use_native = false config.use_native = false
} }
return config return config
} }
function getChannelType(channel: SupportedChannel): string {
return channel.type ?? channel.name
}
function buildSavePayload( function buildSavePayload(
channel: SupportedChannel, channel: SupportedChannel,
editConfig: ChannelConfig, editConfig: ChannelConfig,
enabled: boolean, enabled: boolean,
): ChannelConfig { ): ChannelConfig {
const payload: ChannelConfig = { enabled, type: channel.config_key } const channelType = getChannelType(channel)
const payload: ChannelConfig = { enabled, type: channelType }
const settings: ChannelConfig = {} const settings: ChannelConfig = {}
for (const [key, value] of Object.entries(editConfig)) { 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 settings.use_native = true
} }
if (channel.name === "whatsapp") { if (channelType === "whatsapp") {
settings.use_native = false settings.use_native = false
} }
@ -121,12 +128,13 @@ function isConfigured(
config: ChannelConfig, config: ChannelConfig,
configuredSecrets: readonly string[], configuredSecrets: readonly string[],
): boolean { ): boolean {
const channelType = getChannelType(channel)
const hasValue = (key: string) => const hasValue = (key: string) =>
!isMissingRequiredValue( !isMissingRequiredValue(
getFieldValueForValidation(config, configuredSecrets, key), getFieldValueForValidation(config, configuredSecrets, key),
) )
switch (channel.name) { switch (channelType) {
case "telegram": case "telegram":
return hasValue("token") return hasValue("token")
case "discord": 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 { function isMissingRequiredValue(value: unknown): boolean {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return true return true
@ -232,24 +263,30 @@ const CHANNELS_WITHOUT_DOCS = new Set([
export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { state: gatewayState } = useGateway() const { state: gatewayState } = useGateway()
const navigate = useNavigate()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [creatingChannel, setCreatingChannel] = useState(false)
const [fetchError, setFetchError] = useState("") const [fetchError, setFetchError] = useState("")
const [serverError, setServerError] = useState("") const [serverError, setServerError] = useState("")
const [createChannelError, setCreateChannelError] = useState("")
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}) const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
const [catalogChannels, setCatalogChannels] = useState<SupportedChannel[]>([])
const [channel, setChannel] = useState<SupportedChannel | null>(null) const [channel, setChannel] = useState<SupportedChannel | null>(null)
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({}) const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
const [editConfig, setEditConfig] = useState<ChannelConfig>({}) const [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([]) const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false)
const [newWeixinChannelName, setNewWeixinChannelName] = useState("")
const loadData = useCallback( const loadData = useCallback(
async (silent = false) => { async (silent = false) => {
if (!silent) setLoading(true) if (!silent) setLoading(true)
try { try {
const catalog = await getChannelsCatalog() const catalog = await getChannelsCatalog()
setCatalogChannels(catalog.channels)
const matched = const matched =
catalog.channels.find((item) => item.name === channelName) ?? null catalog.channels.find((item) => item.name === channelName) ?? null
@ -270,16 +307,25 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const channelConfig = await getChannelConfig(channelName) const channelConfig = await getChannelConfig(channelName)
const raw = asRecord(channelConfig.config) const raw = asRecord(channelConfig.config)
const normalized = normalizeConfig(matched, raw) const normalized = normalizeConfig(matched, raw)
const currentType = getChannelType(matched)
setChannel(matched) setChannel(matched)
setBaseConfig(normalized) setBaseConfig(normalized)
setEditConfig(buildEditConfig(matched.name, normalized)) setEditConfig(buildEditConfig(currentType, normalized))
setConfiguredSecrets(channelConfig.configured_secrets ?? []) setConfiguredSecrets(channelConfig.configured_secrets ?? [])
setEnabled(asBool(normalized.enabled)) setEnabled(asBool(normalized.enabled))
setFetchError("") setFetchError("")
setServerError("") setServerError("")
setCreateChannelError("")
setFieldErrors({}) setFieldErrors({})
setNewWeixinChannelName((prev) => {
const next = buildSuggestedWeixinChannelName(
catalog.channels.map((item) => item.name),
)
return prev.trim() === "" ? next : prev
})
} catch (e) { } catch (e) {
setCatalogChannels([])
setConfiguredSecrets([]) setConfiguredSecrets([])
setFetchError(e instanceof Error ? e.message : t("channels.loadError")) setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
} finally { } finally {
@ -314,7 +360,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const docsUrl = useMemo(() => { const docsUrl = useMemo(() => {
if (!channel) return "" if (!channel) return ""
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" const channelType = getChannelType(channel)
if (CHANNELS_WITHOUT_DOCS.has(channelType)) return ""
const language = ( const language = (
i18n.resolvedLanguage ?? i18n.resolvedLanguage ??
i18n.language ?? i18n.language ??
@ -323,7 +370,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const base = language.startsWith("zh") const base = language.startsWith("zh")
? "https://docs.picoclaw.io/zh-Hans/docs/channels" ? "https://docs.picoclaw.io/zh-Hans/docs/channels"
: "https://docs.picoclaw.io/docs/channels" : "https://docs.picoclaw.io/docs/channels"
return `${base}/${getChannelDocSlug(channel.name)}` return `${base}/${getChannelDocSlug(channelType)}`
}, [channel, i18n.language, i18n.resolvedLanguage]) }, [channel, i18n.language, i18n.resolvedLanguage])
const channelDisplayName = useMemo(() => { const channelDisplayName = useMemo(() => {
@ -331,21 +378,24 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
return getChannelDisplayName(channel, t) return getChannelDisplayName(channel, t)
}, [channel, channelName, 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(() => { const hiddenKeys = useMemo(() => {
if (!channel) return [] if (!channel) return []
if (channel.name === "whatsapp") { const currentType = getChannelType(channel)
if (currentType === "whatsapp") {
return ["use_native"] return ["use_native"]
} }
if (channel.name === "whatsapp_native") { if (currentType === "whatsapp_native") {
return ["use_native", "bridge_url"] return ["use_native", "bridge_url"]
} }
return [] return []
}, [channel]) }, [channel])
const requiredKeys = useMemo( const requiredKeys = useMemo(
() => getRequiredFieldKeys(channelName), () => getRequiredFieldKeys(channelType),
[channelName], [channelType],
) )
const handleChange = useCallback((key: string, value: unknown) => { const handleChange = useCallback((key: string, value: unknown) => {
@ -364,7 +414,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const handleReset = () => { const handleReset = () => {
if (!channel) return if (!channel) return
setEditConfig(buildEditConfig(channel.name, baseConfig)) setEditConfig(buildEditConfig(getChannelType(channel), baseConfig))
setEnabled(asBool(baseConfig.enabled)) setEnabled(asBool(baseConfig.enabled))
setServerError("") setServerError("")
setFieldErrors({}) setFieldErrors({})
@ -398,6 +448,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
[channel.config_key]: savePayload, [channel.config_key]: savePayload,
}, },
}) })
notifyChannelsUpdated()
await loadData() await loadData()
} catch (e) { } catch (e) {
const message = const message =
@ -411,6 +462,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const handleWeixinBindSuccess = useCallback(async () => { const handleWeixinBindSuccess = useCallback(async () => {
try { try {
setEnabled(true) setEnabled(true)
notifyChannelsUpdated()
await Promise.all([loadData(true), refreshGatewayState({ force: true })]) await Promise.all([loadData(true), refreshGatewayState({ force: true })])
} catch (e) { } catch (e) {
const message = const message =
@ -423,6 +475,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const handleWecomBindSuccess = useCallback(async () => { const handleWecomBindSuccess = useCallback(async () => {
try { try {
setEnabled(true) setEnabled(true)
notifyChannelsUpdated()
await Promise.all([loadData(true), refreshGatewayState({ force: true })]) await Promise.all([loadData(true), refreshGatewayState({ force: true })])
} catch (e) { } catch (e) {
const message = const message =
@ -450,11 +503,54 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
[loadData, t], [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 = () => { const renderForm = () => {
if (!channel) return null if (!channel) return null
const isEdit = configured const isEdit = configured
const currentType = getChannelType(channel)
switch (channel.name) { switch (currentType) {
case "telegram": case "telegram":
return ( return (
<TelegramForm <TelegramForm
@ -494,6 +590,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
case "weixin": case "weixin":
return ( return (
<WeixinForm <WeixinForm
channel={channel}
config={editConfig} config={editConfig}
onChange={handleChange} onChange={handleChange}
isEdit={isEdit} isEdit={isEdit}
@ -574,6 +671,50 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
</div> </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()} {renderForm()}
{serverError && ( {serverError && (

View file

@ -8,7 +8,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" 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 { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
import { Field } from "@/components/shared-form" import { Field } from "@/components/shared-form"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -31,6 +31,7 @@ type BindingState =
| "error" | "error"
interface WeixinFormProps { interface WeixinFormProps {
channel: SupportedChannel
config: ChannelConfig config: ChannelConfig
onChange: (key: string, value: unknown) => void onChange: (key: string, value: unknown) => void
isEdit: boolean isEdit: boolean
@ -47,6 +48,7 @@ function asStringArray(value: unknown): string[] {
} }
export function WeixinForm({ export function WeixinForm({
channel,
config, config,
onChange, onChange,
isEdit, isEdit,
@ -126,7 +128,7 @@ export function WeixinForm({
setQrDataURI(null) setQrDataURI(null)
stopPolling() stopPolling()
try { try {
const resp = await startWeixinFlow() const resp = await startWeixinFlow(channel.config_key)
setQrDataURI(resp.qr_data_uri ?? null) setQrDataURI(resp.qr_data_uri ?? null)
setBindState("waiting") setBindState("waiting")
startPolling(resp.flow_id) startPolling(resp.flow_id)

View file

@ -112,11 +112,16 @@ function isChannelEnabled(
return true return true
} }
function getChannelType(channel: SupportedChannel): string {
return channel.type ?? channel.name
}
function buildChannelEnabledMap( function buildChannelEnabledMap(
channels: SupportedChannel[], channels: SupportedChannel[],
appConfig: AppConfig, appConfig: AppConfig,
): Record<string, boolean> { ): 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> = {} const result: Record<string, boolean> = {}
for (const channel of channels) { for (const channel of channels) {
result[channel.name] = isChannelEnabled(channel, channelsConfig) result[channel.name] = isChannelEnabled(channel, channelsConfig)
@ -168,8 +173,16 @@ export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) {
React.useEffect(() => { React.useEffect(() => {
let active = true let active = true
reloadChannels(() => active) reloadChannels(() => active)
const handleChannelsUpdated = () => {
reloadChannels(() => active)
}
window.addEventListener("picoclaw:channels-updated", handleChannelsUpdated)
return () => { return () => {
active = false active = false
window.removeEventListener(
"picoclaw:channels-updated",
handleChannelsUpdated,
)
} }
}, [reloadChannels]) }, [reloadChannels])
@ -198,9 +211,9 @@ export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) {
} }
const aImportance = const aImportance =
channelImportanceIndex.get(a.name) ?? Number.MAX_SAFE_INTEGER channelImportanceIndex.get(getChannelType(a)) ?? Number.MAX_SAFE_INTEGER
const bImportance = const bImportance =
channelImportanceIndex.get(b.name) ?? Number.MAX_SAFE_INTEGER channelImportanceIndex.get(getChannelType(b)) ?? Number.MAX_SAFE_INTEGER
if (aImportance !== bImportance) { if (aImportance !== bImportance) {
return aImportance - bImportance return aImportance - bImportance
} }
@ -223,7 +236,7 @@ export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) {
key: channel.name, key: channel.name,
title: getChannelDisplayName(channel, t), title: getChannelDisplayName(channel, t),
url: `/channels/${channel.name}`, url: `/channels/${channel.name}`,
icon: CHANNEL_ICON_MAP[channel.name] ?? IconPlug, icon: CHANNEL_ICON_MAP[getChannelType(channel)] ?? IconPlug,
})), })),
[t, visibleChannels], [t, visibleChannels],
) )

View file

@ -310,10 +310,17 @@
"weixin": { "weixin": {
"bindTitle": "WeChat Account Binding", "bindTitle": "WeChat Account Binding",
"bindDesc": "Scan the QR code with WeChat to bind your personal account.", "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", "bind": "Bind WeChat",
"createChannel": "Create Channel",
"rebind": "Re-bind", "rebind": "Re-bind",
"bound": "WeChat Bound", "bound": "WeChat Bound",
"notBound": "WeChat account not bound yet.", "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...", "generating": "Generating QR code...",
"scanHint": "Open WeChat and scan the QR code", "scanHint": "Open WeChat and scan the QR code",
"scanned": "Scanned — please confirm in WeChat", "scanned": "Scanned — please confirm in WeChat",

View file

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