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 (
|
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)
|
||||||
|
|
|
||||||
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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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 分别绑定。
|
||||||
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。
|
- **频率控制**: 为避免触发微信的风控反垃圾机制,请避免设置死循环触发、高频广播等恶意行为。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "已扫码 — 请在微信中确认",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue