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:
parent
e556a816e4
commit
c4c259c748
18 changed files with 1011 additions and 79 deletions
|
|
@ -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)
|
||||
|
|
|
|||
98
cmd/picoclaw/internal/auth/weixin_test.go
Normal file
98
cmd/picoclaw/internal/auth/weixin_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 分别绑定。
|
||||
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -310,10 +310,17 @@
|
|||
"weixin": {
|
||||
"bindTitle": "微信账号绑定",
|
||||
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||
"addChannelTitle": "新增微信通道",
|
||||
"addChannelDesc": "先创建一个新的微信通道实例,再绑定另一套微信账号。",
|
||||
"bind": "绑定微信",
|
||||
"createChannel": "创建通道",
|
||||
"rebind": "重新绑定",
|
||||
"bound": "微信已绑定",
|
||||
"notBound": "尚未绑定微信账号。",
|
||||
"channelName": "通道名",
|
||||
"channelNameRequired": "请输入通道名。",
|
||||
"duplicateChannel": "通道“{{name}}”已存在。",
|
||||
"invalidChannelName": "通道名只能包含字母、数字、点、下划线和连字符。",
|
||||
"generating": "正在生成二维码...",
|
||||
"scanHint": "打开微信,扫描二维码",
|
||||
"scanned": "已扫码 — 请在微信中确认",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue