Merge branch 'sipeed:main' into main
This commit is contained in:
commit
85e22c61d6
82 changed files with 5353 additions and 571 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -40,6 +40,7 @@ tasks/
|
||||||
|
|
||||||
# Plans
|
# Plans
|
||||||
docs/plans/
|
docs/plans/
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -524,7 +524,7 @@ Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul m
|
||||||
| Commande | Description |
|
| Commande | Description |
|
||||||
| ------------------------- | ---------------------------------------- |
|
| ------------------------- | ---------------------------------------- |
|
||||||
| `picoclaw onboard` | Initialiser la config & le workspace |
|
| `picoclaw onboard` | Initialiser la config & le workspace |
|
||||||
| `picoclaw onboard weixin` | Connecter un compte WeChat via QR |
|
| `picoclaw auth weixin` | Connecter un compte WeChat via QR |
|
||||||
| `picoclaw agent -m "..."` | Chatter avec l'agent |
|
| `picoclaw agent -m "..."` | Chatter avec l'agent |
|
||||||
| `picoclaw agent` | Mode chat interactif |
|
| `picoclaw agent` | Mode chat interactif |
|
||||||
| `picoclaw gateway` | Démarrer le gateway |
|
| `picoclaw gateway` | Démarrer le gateway |
|
||||||
|
|
|
||||||
|
|
@ -520,7 +520,7 @@ Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan mel
|
||||||
| Perintah | Deskripsi |
|
| Perintah | Deskripsi |
|
||||||
| -------------------------- | -------------------------------- |
|
| -------------------------- | -------------------------------- |
|
||||||
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
|
||||||
| `picoclaw onboard weixin` | Hubungkan akun WeChat via QR |
|
| `picoclaw auth weixin` | Hubungkan akun WeChat via QR |
|
||||||
| `picoclaw agent -m "..."` | Chat dengan agent |
|
| `picoclaw agent -m "..."` | Chat dengan agent |
|
||||||
| `picoclaw agent` | Mode chat interaktif |
|
| `picoclaw agent` | Mode chat interaktif |
|
||||||
| `picoclaw gateway` | Mulai gateway |
|
| `picoclaw gateway` | Mulai gateway |
|
||||||
|
|
|
||||||
|
|
@ -520,7 +520,7 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
|
||||||
| Comando | Descrizione |
|
| Comando | Descrizione |
|
||||||
| ------------------------- | ---------------------------------- |
|
| ------------------------- | ---------------------------------- |
|
||||||
| `picoclaw onboard` | Inizializza config & workspace |
|
| `picoclaw onboard` | Inizializza config & workspace |
|
||||||
| `picoclaw onboard weixin` | Connetti account WeChat tramite QR |
|
| `picoclaw auth weixin` | Connetti account WeChat tramite QR |
|
||||||
| `picoclaw agent -m "..."` | Chatta con l'agent |
|
| `picoclaw agent -m "..."` | Chatta con l'agent |
|
||||||
| `picoclaw agent` | Modalità chat interattiva |
|
| `picoclaw agent` | Modalità chat interattiva |
|
||||||
| `picoclaw gateway` | Avvia il gateway |
|
| `picoclaw gateway` | Avvia il gateway |
|
||||||
|
|
|
||||||
|
|
@ -520,7 +520,7 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ
|
||||||
| コマンド | 説明 |
|
| コマンド | 説明 |
|
||||||
| ------------------------- | ------------------------------ |
|
| ------------------------- | ------------------------------ |
|
||||||
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
|
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
|
||||||
| `picoclaw onboard weixin` | WeChat アカウントを QR で接続 |
|
| `picoclaw auth weixin` | WeChat アカウントを QR で接続 |
|
||||||
| `picoclaw agent -m "..."` | Agent とチャット |
|
| `picoclaw agent -m "..."` | Agent とチャット |
|
||||||
| `picoclaw agent` | インタラクティブチャットモード |
|
| `picoclaw agent` | インタラクティブチャットモード |
|
||||||
| `picoclaw gateway` | Gateway を起動 |
|
| `picoclaw gateway` | Gateway を起動 |
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,9 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
|
||||||
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
|
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
|
||||||
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
|
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
|
||||||
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
|
||||||
|
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
|
||||||
|
|
||||||
|
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
<summary><b>Local deployment (Ollama, vLLM, etc.)</b></summary>
|
||||||
|
|
@ -520,7 +523,7 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------- | -------------------------------- |
|
| ------------------------- | -------------------------------- |
|
||||||
| `picoclaw onboard` | Initialize config & workspace |
|
| `picoclaw onboard` | Initialize config & workspace |
|
||||||
| `picoclaw onboard weixin` | Connect WeChat account via QR |
|
| `picoclaw auth weixin` | Connect WeChat account via QR |
|
||||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||||
| `picoclaw agent` | Interactive chat mode |
|
| `picoclaw agent` | Interactive chat mode |
|
||||||
| `picoclaw gateway` | Start the gateway |
|
| `picoclaw gateway` | Start the gateway |
|
||||||
|
|
|
||||||
|
|
@ -520,7 +520,7 @@ Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única men
|
||||||
| Comando | Descrição |
|
| Comando | Descrição |
|
||||||
| ------------------------- | -------------------------------------- |
|
| ------------------------- | -------------------------------------- |
|
||||||
| `picoclaw onboard` | Inicializar config e workspace |
|
| `picoclaw onboard` | Inicializar config e workspace |
|
||||||
| `picoclaw onboard weixin` | Conectar conta WeChat via QR |
|
| `picoclaw auth weixin` | Conectar conta WeChat via QR |
|
||||||
| `picoclaw agent -m "..."` | Conversar com o agent |
|
| `picoclaw agent -m "..."` | Conversar com o agent |
|
||||||
| `picoclaw agent` | Modo de chat interativo |
|
| `picoclaw agent` | Modo de chat interativo |
|
||||||
| `picoclaw gateway` | Iniciar o gateway |
|
| `picoclaw gateway` | Iniciar o gateway |
|
||||||
|
|
|
||||||
|
|
@ -520,7 +520,7 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
|
||||||
| Lệnh | Mô tả |
|
| Lệnh | Mô tả |
|
||||||
| ------------------------- | ---------------------------------------- |
|
| ------------------------- | ---------------------------------------- |
|
||||||
| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
|
| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
|
||||||
| `picoclaw onboard weixin` | Kết nối tài khoản WeChat qua QR |
|
| `picoclaw auth weixin` | Kết nối tài khoản WeChat qua QR |
|
||||||
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
| `picoclaw agent -m "..."` | Trò chuyện với agent |
|
||||||
| `picoclaw agent` | Chế độ trò chuyện tương tác |
|
| `picoclaw agent` | Chế độ trò chuyện tương tác |
|
||||||
| `picoclaw gateway` | Khởi động gateway |
|
| `picoclaw gateway` | Khởi động gateway |
|
||||||
|
|
|
||||||
|
|
@ -520,7 +520,7 @@ PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 M
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| ------------------------- | ---------------------- |
|
| ------------------------- | ---------------------- |
|
||||||
| `picoclaw onboard` | 初始化配置与工作区 |
|
| `picoclaw onboard` | 初始化配置与工作区 |
|
||||||
| `picoclaw onboard weixin` | 扫码连接微信个人号 |
|
| `picoclaw auth weixin` | 扫码连接微信个人号 |
|
||||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||||
| `picoclaw agent` | 交互式对话模式 |
|
| `picoclaw agent` | 交互式对话模式 |
|
||||||
| `picoclaw gateway` | 启动网关 |
|
| `picoclaw gateway` | 启动网关 |
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ func NewAuthCommand() *cobra.Command {
|
||||||
newLogoutCommand(),
|
newLogoutCommand(),
|
||||||
newStatusCommand(),
|
newStatusCommand(),
|
||||||
newModelsCommand(),
|
newModelsCommand(),
|
||||||
|
newWeixinCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ func TestNewAuthCommand(t *testing.T) {
|
||||||
"logout",
|
"logout",
|
||||||
"status",
|
"status",
|
||||||
"models",
|
"models",
|
||||||
|
"weixin",
|
||||||
}
|
}
|
||||||
|
|
||||||
subcommands := cmd.Commands()
|
subcommands := cmd.Commands()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package onboard
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -27,7 +27,7 @@ to authorize your account. On success, the bot token is saved to the picoclaw
|
||||||
config so you can start the gateway immediately.
|
config so you can start the gateway immediately.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
picoclaw onboard 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(baseURL, proxy, time.Duration(timeout)*time.Second)
|
||||||
},
|
},
|
||||||
|
|
@ -16,7 +16,7 @@ func NewOnboardCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "onboard",
|
Use: "onboard",
|
||||||
Aliases: []string{"o"},
|
Aliases: []string{"o"},
|
||||||
Short: "Initialize picoclaw configuration, workspace, and channel accounts",
|
Short: "Initialize picoclaw configuration and workspace",
|
||||||
// Run without subcommands → original onboard flow
|
// Run without subcommands → original onboard flow
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
|
|
@ -30,8 +30,5 @@ func NewOnboardCommand() *cobra.Command {
|
||||||
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||||
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||||
|
|
||||||
// Channel onboarding subcommands
|
|
||||||
cmd.AddCommand(newWeixinCommand())
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func TestNewOnboardCommand(t *testing.T) {
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "onboard", cmd.Use)
|
assert.Equal(t, "onboard", cmd.Use)
|
||||||
assert.Equal(t, "Initialize picoclaw configuration, workspace, and channel accounts", cmd.Short)
|
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 1)
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
assert.True(t, cmd.HasAlias("o"))
|
assert.True(t, cmd.HasAlias("o"))
|
||||||
|
|
@ -28,6 +28,5 @@ func TestNewOnboardCommand(t *testing.T) {
|
||||||
encFlag := cmd.Flags().Lookup("enc")
|
encFlag := cmd.Flags().Lookup("enc")
|
||||||
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
||||||
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
||||||
assert.True(t, cmd.HasSubCommands())
|
assert.False(t, cmd.HasSubCommands())
|
||||||
assert.NotNil(t, cmd.Commands())
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ PicoClaw supports connecting to your personal WeChat account using the official
|
||||||
The easiest way to set up the Weixin channel is using the interactive onboarding command:
|
The easiest way to set up the Weixin channel is using the interactive onboarding command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will:
|
This command will:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
|
||||||
最简单的方法是使用交互式 onboarding 命令进行一键激活:
|
最简单的方法是使用交互式 onboarding 命令进行一键激活:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令将:
|
该命令将:
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ PicoClaw supports connecting to your personal WeChat account using the official
|
||||||
|
|
||||||
Run the interactive QR login flow:
|
Run the interactive QR login flow:
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
|
Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -754,6 +754,7 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace
|
||||||
|
|
||||||
| Topic | Description |
|
| Topic | Description |
|
||||||
| ----- | ----------- |
|
| ----- | ----------- |
|
||||||
|
| [Sensitive Data Filtering](sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM |
|
||||||
| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks |
|
| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks |
|
||||||
| [Steering](steering.md) | Inject messages into a running agent loop between tool calls |
|
| [Steering](steering.md) | Inject messages into a running agent loop between tool calls |
|
||||||
| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle |
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ PicoClaw prend en charge la connexion à votre compte WeChat personnel via l'API
|
||||||
|
|
||||||
Lancez le flux de connexion interactif par QR code :
|
Lancez le flux de connexion interactif par QR code :
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
Scannez le QR code affiché avec votre application WeChat mobile. Une fois connecté, le token est sauvegardé dans votre configuration.
|
Scannez le QR code affiché avec votre application WeChat mobile. Une fois connecté, le token est sauvegardé dans votre configuration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ PicoClaw は Tencent iLink 公式 API を使用して WeChat 個人アカウン
|
||||||
|
|
||||||
インタラクティブな QR ログインフローを実行します:
|
インタラクティブな QR ログインフローを実行します:
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
WeChat モバイルアプリで表示された QR コードをスキャンしてください。ログイン成功後、トークンが設定ファイルに保存されます。
|
WeChat モバイルアプリで表示された QR コードをスキャンしてください。ログイン成功後、トークンが設定ファイルに保存されます。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ O PicoClaw suporta conexão com sua conta pessoal do WeChat usando a API oficial
|
||||||
|
|
||||||
Execute o fluxo de login interativo por QR code:
|
Execute o fluxo de login interativo por QR code:
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-sucedido, o token é salvo na sua configuração.
|
Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-sucedido, o token é salvo na sua configuração.
|
||||||
|
|
||||||
|
|
|
||||||
107
docs/sensitive_data_filtering.md
Normal file
107
docs/sensitive_data_filtering.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Sensitive Data Filtering
|
||||||
|
|
||||||
|
PicoClaw can filter sensitive values (API keys, tokens, secrets, passwords) from tool call results before they are sent to the LLM. This prevents the LLM from seeing its own credentials, which could otherwise leak through tool output or cause confusing behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When the LLM uses a tool that returns its own credentials (e.g., a tool that echoes the API key being used), those values are automatically replaced with `[FILTERED]` in the message sent to the LLM.
|
||||||
|
|
||||||
|
Sensitive values are collected from [`.security.yml`](./credential_encryption.md) — the centralized storage for all sensitive configuration (API keys, tokens, secrets stored alongside `config.json`). This includes:
|
||||||
|
|
||||||
|
- Model API keys
|
||||||
|
- Channel tokens (Telegram, Discord, Slack, Matrix, etc.)
|
||||||
|
- Web tool API keys (Brave, Tavily, Perplexity, etc.)
|
||||||
|
- Skills tokens (GitHub, ClawHub)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Sensitive data filtering is configured in the `tools` section of `config.json`:
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `filter_sensitive_data` | bool | `true` | Enable/disable filtering. When `false`, no filtering is performed. |
|
||||||
|
| `filter_min_length` | int | `8` | Minimum content length to trigger filtering. Short content is skipped for performance. |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"filter_sensitive_data": true,
|
||||||
|
"filter_min_length": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variable
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | Set to `true` or `false` to override the config value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **On startup**: All sensitive values are collected from `.security.yml` using reflection and compiled into a `strings.Replacer` (O(n+m) performance, computed once).
|
||||||
|
|
||||||
|
2. **Per tool result**: Before sending any tool result content to the LLM:
|
||||||
|
- If `filter_sensitive_data` is `false`, content is passed through unchanged
|
||||||
|
- If content length < `filter_min_length`, content is passed through unchanged (fast path)
|
||||||
|
- Otherwise, all sensitive values are replaced with `[FILTERED]`
|
||||||
|
|
||||||
|
3. **Replacement**: Uses `strings.Replacer` for efficient O(n+m) string substitution, where n = content length and m = total sensitive value length.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Given the following `.security.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
my-model:
|
||||||
|
api_keys:
|
||||||
|
- sk-secret-key-12345
|
||||||
|
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "123456:ABC-DEF"
|
||||||
|
```
|
||||||
|
|
||||||
|
And a tool result containing:
|
||||||
|
|
||||||
|
```
|
||||||
|
The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF
|
||||||
|
```
|
||||||
|
|
||||||
|
The LLM will receive:
|
||||||
|
|
||||||
|
```
|
||||||
|
The model is using API key [FILTERED] and Telegram bot [FILTERED]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Fast path**: Content shorter than `filter_min_length` (default 8) is returned unchanged without any string scanning
|
||||||
|
- **Efficient replacement**: Uses `strings.Replacer` with O(n+m) complexity instead of regex
|
||||||
|
- **Lazy initialization**: The replacement map is built once on first access via `sync.Once`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Credential exposure prevention**: Without filtering, tools that echo credentials could cause the LLM to see its own API keys, potentially leading to confusion or credential leakage in logs
|
||||||
|
- **Defense in depth**: Filtering complements (but does not replace) credential encryption — both features should be used together
|
||||||
|
- **No false positives**: Only values explicitly stored in `.security.yml` are filtered; the LLM's general knowledge is unaffected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Credential Encryption](./credential_encryption.md) — encrypting API keys in config
|
||||||
|
- [Tools Configuration](./tools_configuration.md)
|
||||||
|
|
@ -26,6 +26,17 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Sensitive Data Filtering
|
||||||
|
|
||||||
|
Before tool results are sent to the LLM, PicoClaw can filter sensitive values (API keys, tokens, secrets) from the output. This prevents the LLM from seeing its own credentials.
|
||||||
|
|
||||||
|
See [Sensitive Data Filtering](../sensitive_data_filtering.md) for full documentation.
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `filter_sensitive_data` | bool | `true` | Enable/disable filtering |
|
||||||
|
| `filter_min_length` | int | `8` | Minimum content length to trigger filtering |
|
||||||
|
|
||||||
## Web Tools
|
## Web Tools
|
||||||
|
|
||||||
Web tools are used for web search and fetching.
|
Web tools are used for web search and fetching.
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ PicoClaw hỗ trợ kết nối với tài khoản WeChat cá nhân của bạn
|
||||||
|
|
||||||
Chạy luồng đăng nhập QR tương tác:
|
Chạy luồng đăng nhập QR tương tác:
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sau khi đăng nhập thành công, token sẽ được lưu vào cấu hình.
|
Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sau khi đăng nhập thành công, token sẽ được lưu vào cấu hình.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ PicoClaw 通过腾讯 iLink 官方 API 支持连接微信个人号。
|
||||||
|
|
||||||
运行交互式扫码登录流程:
|
运行交互式扫码登录流程:
|
||||||
```bash
|
```bash
|
||||||
picoclaw onboard weixin
|
picoclaw auth weixin
|
||||||
```
|
```
|
||||||
用微信手机端扫描打印出的二维码。登录成功后,token 会自动保存到配置文件。
|
用微信手机端扫描打印出的二维码。登录成功后,token 会自动保存到配置文件。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,7 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设
|
||||||
|
|
||||||
| 主题 | 说明 |
|
| 主题 | 说明 |
|
||||||
| ---- | ---- |
|
| ---- | ---- |
|
||||||
|
| [敏感数据过滤](../sensitive_data_filtering.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 |
|
||||||
| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
|
||||||
| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
|
||||||
| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
|
||||||
|
|
|
||||||
107
docs/zh/sensitive_data_filtering.md
Normal file
107
docs/zh/sensitive_data_filtering.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# 敏感数据过滤
|
||||||
|
|
||||||
|
PicoClaw 可以从工具调用结果中过滤敏感值(API 密钥、令牌、密码等),然后再发送给 LLM。这可以防止 LLM 看到自己的凭据,避免通过工具输出泄露或产生混淆行为。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
当 LLM 使用的工具返回其自身的凭据时(例如,一个回显正在使用的 API 密钥的工具),这些值会自动替换为 `[FILTERED]` 再发送给 LLM。
|
||||||
|
|
||||||
|
敏感值从 `.security.yml` 中收集 —— 这是所有敏感配置的集中存储,包括:
|
||||||
|
|
||||||
|
- 模型 API 密钥
|
||||||
|
- 频道令牌(Telegram、Discord、Slack、Matrix 等)
|
||||||
|
- Web 工具 API 密钥(Brave、Tavily、Perplexity 等)
|
||||||
|
- 技能令牌(GitHub、ClawHub)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
敏感数据过滤在 `config.json` 的 `tools` 部分配置:
|
||||||
|
|
||||||
|
| 配置 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤。为 `false` 时,不进行任何过滤。 |
|
||||||
|
| `filter_min_length` | int | `8` | 触发过滤的最小内容长度。短内容会被跳过以提高性能。 |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"filter_sensitive_data": true,
|
||||||
|
"filter_min_length": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA` | 设置为 `true` 或 `false` 以覆盖配置值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
1. **启动时**:使用反射从 `.security.yml` 中收集所有敏感值,并编译成 `strings.Replacer`(O(n+m) 性能,仅计算一次)。
|
||||||
|
|
||||||
|
2. **每个工具结果**:在将任何工具结果发送给 LLM 之前:
|
||||||
|
- 如果 `filter_sensitive_data` 为 `false`,内容原样传递
|
||||||
|
- 如果内容长度 < `filter_min_length`,内容原样传递(快速路径)
|
||||||
|
- 否则,所有敏感值都会被替换为 `[FILTERED]`
|
||||||
|
|
||||||
|
3. **替换**:使用 `strings.Replacer` 进行高效的 O(n+m) 字符串替换,其中 n = 内容长度,m = 敏感值总长度。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
给定以下 `.security.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model_list:
|
||||||
|
my-model:
|
||||||
|
api_keys:
|
||||||
|
- sk-secret-key-12345
|
||||||
|
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: "123456:ABC-DEF"
|
||||||
|
```
|
||||||
|
|
||||||
|
以及包含以下内容的工具结果:
|
||||||
|
|
||||||
|
```
|
||||||
|
The model is using API key sk-secret-key-12345 and Telegram bot 123456:ABC-DEF
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM 将收到:
|
||||||
|
|
||||||
|
```
|
||||||
|
The model is using API key [FILTERED] and Telegram bot [FILTERED]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能
|
||||||
|
|
||||||
|
- **快速路径**:短于 `filter_min_length`(默认 8)的内容会直接返回,不进行任何字符串扫描
|
||||||
|
- **高效替换**:使用 `strings.Replacer`,复杂度为 O(n+m),而非正则表达式
|
||||||
|
- **延迟初始化**:替换映射通过 `sync.Once` 在首次访问时构建一次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
- **凭据泄露防护**:如果没有过滤,返回凭据的工具可能导致 LLM 看到自己的 API 密钥,可能导致日志中泄露凭据或产生混淆
|
||||||
|
- **纵深防御**:过滤是对凭据加密的补充(而非替代)—— 应同时使用这两个功能
|
||||||
|
- **无误报**:只有明确存储在 `.security.yml` 中的值才会被过滤;LLM 的通用知识不受影响
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [凭据加密](../credential_encryption.md) — 配置中 API 密钥的加密
|
||||||
|
- [工具配置](../tools_configuration.md)
|
||||||
|
|
@ -28,6 +28,17 @@ PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 敏感数据过滤
|
||||||
|
|
||||||
|
在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。
|
||||||
|
|
||||||
|
详细说明请参阅[敏感数据过滤](../sensitive_data_filtering.md)。
|
||||||
|
|
||||||
|
| 配置项 | 类型 | 默认值 | 描述 |
|
||||||
|
|--------|------|--------|------|
|
||||||
|
| `filter_sensitive_data` | bool | `true` | 启用/禁用过滤 |
|
||||||
|
| `filter_min_length` | int | `8` | 触发过滤的最小内容长度 |
|
||||||
|
|
||||||
## Web 工具
|
## Web 工具
|
||||||
|
|
||||||
Web 工具用于网页搜索和抓取。
|
Web 工具用于网页搜索和抓取。
|
||||||
|
|
|
||||||
16
go.mod
16
go.mod
|
|
@ -7,6 +7,9 @@ require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
github.com/ergochat/irc-go v0.6.0
|
github.com/ergochat/irc-go v0.6.0
|
||||||
|
|
@ -40,6 +43,19 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.2 // indirect
|
||||||
github.com/beeper/argo-go v1.1.2 // indirect
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
|
|
||||||
32
go.sum
32
go.sum
|
|
@ -17,6 +17,38 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||||
|
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||||
|
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
|
|
|
||||||
|
|
@ -1733,7 +1733,8 @@ turnLoop:
|
||||||
select {
|
select {
|
||||||
case result, ok := <-ts.pendingResults:
|
case result, ok := <-ts.pendingResults:
|
||||||
if ok && result != nil && result.ForLLM != "" {
|
if ok && result != nil && result.ForLLM != "" {
|
||||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", result.ForLLM)}
|
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||||
|
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||||
pendingMessages = append(pendingMessages, msg)
|
pendingMessages = append(pendingMessages, msg)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
@ -2091,9 +2092,13 @@ turnLoop:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reasoningContent := response.Reasoning
|
||||||
|
if reasoningContent == "" {
|
||||||
|
reasoningContent = response.ReasoningContent
|
||||||
|
}
|
||||||
go al.handleReasoning(
|
go al.handleReasoning(
|
||||||
turnCtx,
|
turnCtx,
|
||||||
response.Reasoning,
|
reasoningContent,
|
||||||
ts.channel,
|
ts.channel,
|
||||||
al.targetReasoningChannelID(ts.channel),
|
al.targetReasoningChannelID(ts.channel),
|
||||||
)
|
)
|
||||||
|
|
@ -2336,6 +2341,9 @@ turnLoop:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter sensitive data before publishing
|
||||||
|
content = al.cfg.FilterSensitiveData(content)
|
||||||
|
|
||||||
logger.InfoCF("agent", "Async tool completed, publishing result",
|
logger.InfoCF("agent", "Async tool completed, publishing result",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": asyncToolName,
|
"tool": asyncToolName,
|
||||||
|
|
@ -2451,6 +2459,11 @@ turnLoop:
|
||||||
contentForLLM = toolResult.Err.Error()
|
contentForLLM = toolResult.Err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter sensitive data (API keys, tokens, secrets) before sending to LLM
|
||||||
|
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
|
||||||
|
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
toolResultMsg := providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: contentForLLM,
|
Content: contentForLLM,
|
||||||
|
|
@ -2528,7 +2541,8 @@ turnLoop:
|
||||||
select {
|
select {
|
||||||
case result, ok := <-ts.pendingResults:
|
case result, ok := <-ts.pendingResults:
|
||||||
if ok && result != nil && result.ForLLM != "" {
|
if ok && result != nil && result.ForLLM != "" {
|
||||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", result.ForLLM)}
|
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||||
|
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,29 @@ func (m *simpleMockProvider) GetDefaultModel() string {
|
||||||
return "mock-model"
|
return "mock-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reasoningContentProvider struct {
|
||||||
|
response string
|
||||||
|
reasoningContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *reasoningContentProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: m.response,
|
||||||
|
ReasoningContent: m.reasoningContent,
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *reasoningContentProvider) GetDefaultModel() string {
|
||||||
|
return "reasoning-content-model"
|
||||||
|
}
|
||||||
|
|
||||||
type countingMockProvider struct {
|
type countingMockProvider struct {
|
||||||
response string
|
response string
|
||||||
calls int
|
calls int
|
||||||
|
|
@ -615,6 +638,7 @@ func (m *mockCustomTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{},
|
"properties": map[string]any{},
|
||||||
|
"additionalProperties": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1688,6 +1712,62 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
ModelName: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &reasoningContentProvider{
|
||||||
|
response: "final answer",
|
||||||
|
reasoningContent: "thinking trace",
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
chManager, err := channels.NewManager(&config.Config{}, msgBus, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel manager: %v", err)
|
||||||
|
}
|
||||||
|
chManager.RegisterChannel("telegram", &fakeChannel{id: "reason-chat"})
|
||||||
|
al.SetChannelManager(chManager)
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "final answer" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "final answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case outbound := <-msgBus.OutboundChan():
|
||||||
|
if outbound.Channel != "telegram" {
|
||||||
|
t.Fatalf("reasoning channel = %q, want %q", outbound.Channel, "telegram")
|
||||||
|
}
|
||||||
|
if outbound.ChatID != "reason-chat" {
|
||||||
|
t.Fatalf("reasoning chatID = %q, want %q", outbound.ChatID, "reason-chat")
|
||||||
|
}
|
||||||
|
if outbound.Content != "thinking trace" {
|
||||||
|
t.Fatalf("reasoning content = %q, want %q", outbound.Content, "thinking trace")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected reasoning content to be published to reasoning channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
|
||||||
store := media.NewFileMediaStore()
|
store := media.NewFileMediaStore()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,7 @@ type qqMediaUpload struct {
|
||||||
FileType uint64 `json:"file_type"`
|
FileType uint64 `json:"file_type"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
FileData string `json:"file_data,omitempty"`
|
FileData string `json:"file_data,omitempty"`
|
||||||
|
FileName string `json:"file_name,omitempty"`
|
||||||
SrvSendMsg bool `json:"srv_send_msg,omitempty"`
|
SrvSendMsg bool `json:"srv_send_msg,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,6 +394,7 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
||||||
if isHTTPURL(mediaRef) {
|
if isHTTPURL(mediaRef) {
|
||||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||||
payload.URL = mediaRef
|
payload.URL = mediaRef
|
||||||
|
payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType)
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,9 +417,11 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
||||||
if isHTTPURL(resolved) {
|
if isHTTPURL(resolved) {
|
||||||
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
payload.FileType = qqFileType(c.outboundMediaType(part, ""))
|
||||||
payload.URL = resolved
|
payload.URL = resolved
|
||||||
|
payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
|
payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
|
||||||
|
payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
|
||||||
|
|
||||||
if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
|
if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
|
||||||
info, statErr := os.Stat(resolved)
|
info, statErr := os.Stat(resolved)
|
||||||
|
|
@ -444,6 +448,28 @@ func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error)
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func qqUploadFilename(part bus.MediaPart, resolved string, fileType uint64) string {
|
||||||
|
if fileType != qqFileType("file") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if part.Filename != "" {
|
||||||
|
return part.Filename
|
||||||
|
}
|
||||||
|
if isHTTPURL(resolved) {
|
||||||
|
if parsed, err := url.Parse(resolved); err == nil {
|
||||||
|
if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if base := filepath.Base(resolved); base != "" && base != "." {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string {
|
func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string {
|
||||||
if part.Type != "audio" {
|
if part.Type != "audio" {
|
||||||
return part.Type
|
return part.Type
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,9 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
|
||||||
if upload.body.FileType != 4 {
|
if upload.body.FileType != 4 {
|
||||||
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
|
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
|
||||||
}
|
}
|
||||||
|
if upload.body.FileName != "report.pdf" {
|
||||||
|
t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName)
|
||||||
|
}
|
||||||
|
|
||||||
if len(api.c2cMessages) != 1 {
|
if len(api.c2cMessages) != 1 {
|
||||||
t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages))
|
t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages))
|
||||||
|
|
@ -460,6 +463,59 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
|
||||||
|
localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf"))
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: "report.pdf",
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
}, "qq:test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
api := &fakeQQAPI{
|
||||||
|
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("local-file-info")}),
|
||||||
|
}
|
||||||
|
ch := &QQChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
||||||
|
api: api,
|
||||||
|
dedup: make(map[string]time.Time),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
ch.SetRunning(true)
|
||||||
|
ch.SetMediaStore(store)
|
||||||
|
ch.chatType.Store("user-1", "direct")
|
||||||
|
|
||||||
|
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||||
|
ChatID: "user-1",
|
||||||
|
Parts: []bus.MediaPart{{
|
||||||
|
Type: "file",
|
||||||
|
Ref: ref,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMedia() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(api.transportCalls) != 1 {
|
||||||
|
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
|
||||||
|
}
|
||||||
|
upload := api.transportCalls[0]
|
||||||
|
if upload.body.FileType != 4 {
|
||||||
|
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
|
||||||
|
}
|
||||||
|
if upload.body.FileName != "report.pdf" {
|
||||||
|
t.Fatalf("upload file_name = %q, want report.pdf", upload.body.FileName)
|
||||||
|
}
|
||||||
|
if upload.body.FileData == "" {
|
||||||
|
t.Fatal("upload file_data = empty, want base64 payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {
|
func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch := &QQChannel{
|
ch := &QQChannel{
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,18 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
_, err = c.bot.SendDocument(ctx, docParams)
|
_, err = c.bot.SendDocument(ctx, docParams)
|
||||||
}
|
}
|
||||||
case "audio":
|
case "audio":
|
||||||
|
// Send OGG files with "voice" in the filename as Telegram voice
|
||||||
|
// bubbles (SendVoice) instead of audio attachments (SendAudio).
|
||||||
|
fn := strings.ToLower(part.Filename)
|
||||||
|
if strings.Contains(fn, "voice") && (strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".oga")) {
|
||||||
|
vparams := &telego.SendVoiceParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
|
Voice: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendVoice(ctx, vparams)
|
||||||
|
} else {
|
||||||
params := &telego.SendAudioParams{
|
params := &telego.SendAudioParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
MessageThreadID: threadID,
|
MessageThreadID: threadID,
|
||||||
|
|
@ -488,6 +500,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
Caption: part.Caption,
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
_, err = c.bot.SendAudio(ctx, params)
|
_, err = c.bot.SendAudio(ctx, params)
|
||||||
|
}
|
||||||
case "video":
|
case "video":
|
||||||
params := &telego.SendVideoParams{
|
params := &telego.SendVideoParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ func (c *Config) WithSecurity(sec *SecurityConfig) *Config {
|
||||||
c.security = sec
|
c.security = sec
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
sec = normalizeSecurityConfig(sec)
|
||||||
err := applySecurityConfig(c, sec)
|
err := applySecurityConfig(c, sec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -114,6 +115,25 @@ func (c *Config) WithSecurity(sec *SecurityConfig) *Config {
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterSensitiveData filters sensitive values from content before sending to LLM.
|
||||||
|
// This prevents the LLM from seeing its own credentials.
|
||||||
|
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).
|
||||||
|
// Short content (below FilterMinLength) is returned unchanged for performance.
|
||||||
|
func (c *Config) FilterSensitiveData(content string) string {
|
||||||
|
if c.security == nil || content == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
// Check if filtering is enabled (default: true)
|
||||||
|
if !c.Tools.IsFilterSensitiveDataEnabled() {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
// Fast path: skip filtering for short content
|
||||||
|
if len(content) < c.Tools.GetFilterMinLength() {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return c.security.SensitiveDataReplacer().Replace(content)
|
||||||
|
}
|
||||||
|
|
||||||
type HooksConfig struct {
|
type HooksConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Defaults HookDefaultsConfig `json:"defaults,omitempty"`
|
Defaults HookDefaultsConfig `json:"defaults,omitempty"`
|
||||||
|
|
@ -795,6 +815,7 @@ func (c *WeComAIBotConfig) SetSecret(secret string) {
|
||||||
type WeixinConfig struct {
|
type WeixinConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
|
||||||
token string
|
token string
|
||||||
|
AccountID string `json:"account_id,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
|
||||||
CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
|
CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
|
||||||
|
|
@ -911,6 +932,7 @@ type DevicesConfig struct {
|
||||||
type VoiceConfig struct {
|
type VoiceConfig struct {
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
|
||||||
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
|
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
|
||||||
|
ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelConfig represents a model-centric provider configuration.
|
// ModelConfig represents a model-centric provider configuration.
|
||||||
|
|
@ -1203,6 +1225,14 @@ type ReadFileToolConfig struct {
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
|
||||||
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
|
||||||
|
// FilterSensitiveData controls whether to filter sensitive values (API keys,
|
||||||
|
// tokens, secrets) from tool results before sending to the LLM.
|
||||||
|
// Default: true (enabled)
|
||||||
|
FilterSensitiveData bool `json:"filter_sensitive_data" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"`
|
||||||
|
// FilterMinLength is the minimum content length required for filtering.
|
||||||
|
// Content shorter than this will be returned unchanged for performance.
|
||||||
|
// Default: 8
|
||||||
|
FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"`
|
||||||
Web WebToolsConfig `json:"web"`
|
Web WebToolsConfig `json:"web"`
|
||||||
Cron CronToolsConfig `json:"cron"`
|
Cron CronToolsConfig `json:"cron"`
|
||||||
Exec ExecConfig `json:"exec"`
|
Exec ExecConfig `json:"exec"`
|
||||||
|
|
@ -1226,6 +1256,19 @@ type ToolsConfig struct {
|
||||||
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled
|
||||||
|
func (c *ToolsConfig) IsFilterSensitiveDataEnabled() bool {
|
||||||
|
return c.FilterSensitiveData
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFilterMinLength returns the minimum content length for filtering (default: 8)
|
||||||
|
func (c *ToolsConfig) GetFilterMinLength() int {
|
||||||
|
if c.FilterMinLength <= 0 {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
return c.FilterMinLength
|
||||||
|
}
|
||||||
|
|
||||||
type SearchCacheConfig struct {
|
type SearchCacheConfig struct {
|
||||||
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
|
||||||
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
|
||||||
|
|
@ -1309,11 +1352,14 @@ type MCPConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
logger.Debugf("loading config from %s", path)
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
logger.WarnF("config file not found, using default config", map[string]any{"path": path})
|
||||||
return DefaultConfig(), nil
|
return DefaultConfig(), nil
|
||||||
}
|
}
|
||||||
|
logger.Errorf("failed to read config file: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1325,6 +1371,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, fmt.Errorf("failed to detect config version: %w", e)
|
return nil, fmt.Errorf("failed to detect config version: %w", e)
|
||||||
}
|
}
|
||||||
if len(data) <= 10 {
|
if len(data) <= 10 {
|
||||||
|
logger.Warn(fmt.Sprintf("content is [%s]", string(data)))
|
||||||
return DefaultConfig().WithSecurity(&SecurityConfig{}), nil
|
return DefaultConfig().WithSecurity(&SecurityConfig{}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1340,23 +1387,35 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
}
|
}
|
||||||
cfg, e = v.Migrate()
|
cfg, e = v.Migrate()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
logger.DebugF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
logger.DebugF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
|
||||||
defer func() {
|
err = makeBackup(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Load existing security config and merge with migrated one to prevent data loss
|
||||||
|
existingSec, secErr := loadSecurityConfig(securityPath(path))
|
||||||
|
if secErr != nil {
|
||||||
|
logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr})
|
||||||
|
}
|
||||||
|
if existingSec != nil && cfg.security != nil {
|
||||||
|
cfg.security = mergeSecurityConfig(existingSec, cfg.security)
|
||||||
|
// Re-apply the merged security config to update all channels and models
|
||||||
|
if err = applySecurityConfig(cfg, cfg.security); err != nil {
|
||||||
|
logger.WarnF("failed to re-apply merged security config during migration", map[string]any{"error": err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func(cfg *Config) {
|
||||||
_ = SaveConfig(path, cfg)
|
_ = SaveConfig(path, cfg)
|
||||||
}()
|
}(cfg)
|
||||||
case CurrentVersion:
|
case CurrentVersion:
|
||||||
// Current version
|
// Current version
|
||||||
cfg, err = loadConfig(data)
|
cfg, err = loadConfig(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load security configuration
|
// Load security configuration
|
||||||
securityPath := securityPath(path)
|
securityPath := securityPath(path)
|
||||||
sec, err := loadSecurityConfig(securityPath)
|
sec, err := loadSecurityConfig(securityPath)
|
||||||
|
|
@ -1369,6 +1428,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
if err := applySecurityConfig(cfg, sec); err != nil {
|
if err := applySecurityConfig(cfg, sec); err != nil {
|
||||||
return nil, fmt.Errorf("failed to apply security config: %w", err)
|
return nil, fmt.Errorf("failed to apply security config: %w", err)
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
|
||||||
|
}
|
||||||
|
|
||||||
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||||
for _, m := range cfg.ModelList {
|
for _, m := range cfg.ModelList {
|
||||||
|
|
@ -1421,6 +1483,19 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeBackup(path string) error {
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Create backup of the config file before migration
|
||||||
|
bakPath := path + ".bak"
|
||||||
|
if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil {
|
||||||
|
logger.ErrorF("failed to create config backup", map[string]any{"error": err})
|
||||||
|
return fmt.Errorf("failed to create config backup: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func copyArray[T any](dst, src *[]T) {
|
func copyArray[T any](dst, src *[]T) {
|
||||||
*dst = make([]T, len(*src))
|
*dst = make([]T, len(*src))
|
||||||
copy(*dst, *src)
|
copy(*dst, *src)
|
||||||
|
|
@ -1433,6 +1508,7 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sec.Web != nil {
|
||||||
if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 {
|
if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 {
|
||||||
copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys)
|
copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys)
|
||||||
}
|
}
|
||||||
|
|
@ -1452,7 +1528,9 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
||||||
if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" {
|
if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" {
|
||||||
cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey
|
cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec.Skills != nil {
|
||||||
if sec.Skills.Github != nil && sec.Skills.Github.Token != "" {
|
if sec.Skills.Github != nil && sec.Skills.Github.Token != "" {
|
||||||
cfg.Tools.Skills.Github.token = sec.Skills.Github.Token
|
cfg.Tools.Skills.Github.token = sec.Skills.Github.Token
|
||||||
}
|
}
|
||||||
|
|
@ -1460,6 +1538,7 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
||||||
if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" {
|
if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" {
|
||||||
cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken
|
cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
names := toNameIndex(cfg.ModelList)
|
names := toNameIndex(cfg.ModelList)
|
||||||
for i, model := range cfg.ModelList {
|
for i, model := range cfg.ModelList {
|
||||||
|
|
@ -1480,6 +1559,7 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sec.Channels != nil {
|
||||||
// Handle Telegram token
|
// Handle Telegram token
|
||||||
if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" {
|
if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" {
|
||||||
cfg.Channels.Telegram.token = sec.Channels.Telegram.Token
|
cfg.Channels.Telegram.token = sec.Channels.Telegram.Token
|
||||||
|
|
@ -1505,7 +1585,7 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
||||||
|
|
||||||
// Handle Weixin token
|
// Handle Weixin token
|
||||||
if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" {
|
if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" {
|
||||||
cfg.Channels.Discord.token = sec.Channels.Discord.Token
|
cfg.Channels.Weixin.token = sec.Channels.Weixin.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle DingTalk client secret
|
// Handle DingTalk client secret
|
||||||
|
|
@ -1601,6 +1681,7 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
|
||||||
if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" {
|
if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" {
|
||||||
cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret
|
cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg.security = sec
|
cfg.security = sec
|
||||||
|
|
||||||
|
|
@ -1701,6 +1782,7 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
logger.ErrorC("config", "security is nil")
|
logger.ErrorC("config", "security is nil")
|
||||||
return fmt.Errorf("security is nil")
|
return fmt.Errorf("security is nil")
|
||||||
}
|
}
|
||||||
|
cfg.security = normalizeSecurityConfig(cfg.security)
|
||||||
// Ensure version is always set when saving
|
// Ensure version is always set when saving
|
||||||
if cfg.Version == 0 {
|
if cfg.Version == 0 {
|
||||||
cfg.Version = CurrentVersion
|
cfg.Version = CurrentVersion
|
||||||
|
|
@ -1879,6 +1961,7 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
logger.Infof("saving config to %s", path)
|
||||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1942,6 +2025,17 @@ func (c *Config) ValidateModelList() error {
|
||||||
|
|
||||||
func (c *Config) SecurityCopyFrom(cfg *Config) {
|
func (c *Config) SecurityCopyFrom(cfg *Config) {
|
||||||
c.security = cfg.security
|
c.security = cfg.security
|
||||||
|
if c.security != nil {
|
||||||
|
if err := applySecurityConfig(c, c.security); err != nil {
|
||||||
|
logger.Errorf("failed to apply security config in SecurityCopyFrom: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySecurity re-applies the stored security config to populate private fields (tokens, API keys, etc.).
|
||||||
|
// Call this after SecurityCopyFrom when you need private fields to be accessible for validation or use.
|
||||||
|
func (c *Config) ApplySecurity() error {
|
||||||
|
return applySecurityConfig(c, c.security)
|
||||||
}
|
}
|
||||||
|
|
||||||
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "encoding/json"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
type agentDefaultsV0 struct {
|
type agentDefaultsV0 struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
|
|
@ -139,21 +141,21 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
|
||||||
Pico: pico,
|
Pico: pico,
|
||||||
IRC: irc,
|
IRC: irc,
|
||||||
}, ChannelsSecurity{
|
}, ChannelsSecurity{
|
||||||
Telegram: &telegramSecurity,
|
Telegram: telegramSecurity,
|
||||||
Feishu: &feishuSecurity,
|
Feishu: feishuSecurity,
|
||||||
Discord: &discordSecurity,
|
Discord: discordSecurity,
|
||||||
QQ: &qqSecurity,
|
QQ: qqSecurity,
|
||||||
Weixin: &weixinSecurity,
|
Weixin: weixinSecurity,
|
||||||
DingTalk: &dingtalkSecurity,
|
DingTalk: dingtalkSecurity,
|
||||||
Slack: &slackSecurity,
|
Slack: slackSecurity,
|
||||||
Matrix: &matrixSecurity,
|
Matrix: matrixSecurity,
|
||||||
LINE: &lineSecurity,
|
LINE: lineSecurity,
|
||||||
OneBot: &onebotSecurity,
|
OneBot: onebotSecurity,
|
||||||
WeCom: &wecomSecurity,
|
WeCom: wecomSecurity,
|
||||||
WeComApp: &wecomappSecurity,
|
WeComApp: wecomappSecurity,
|
||||||
WeComAIBot: &wecomaibotSecurity,
|
WeComAIBot: wecomaibotSecurity,
|
||||||
Pico: &picoSecurity,
|
Pico: picoSecurity,
|
||||||
IRC: &ircSecurity,
|
IRC: ircSecurity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +171,13 @@ type qqConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *qqConfigV0) ToQQConfig() (QQConfig, QQSecurity) {
|
func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) {
|
||||||
|
var sec *QQSecurity
|
||||||
|
if v.AppSecret != "" {
|
||||||
|
sec = &QQSecurity{
|
||||||
|
AppSecret: v.AppSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
return QQConfig{
|
return QQConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
AppID: v.AppID,
|
AppID: v.AppID,
|
||||||
|
|
@ -179,9 +187,7 @@ func (v *qqConfigV0) ToQQConfig() (QQConfig, QQSecurity) {
|
||||||
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
|
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
|
||||||
SendMarkdown: v.SendMarkdown,
|
SendMarkdown: v.SendMarkdown,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, QQSecurity{
|
}, sec
|
||||||
AppSecret: v.AppSecret,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type telegramConfigV0 struct {
|
type telegramConfigV0 struct {
|
||||||
|
|
@ -197,7 +203,13 @@ type telegramConfigV0 struct {
|
||||||
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, TelegramSecurity) {
|
func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity) {
|
||||||
|
var sec *TelegramSecurity
|
||||||
|
if v.Token != "" {
|
||||||
|
sec = &TelegramSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
return TelegramConfig{
|
return TelegramConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
token: v.Token,
|
||||||
|
|
@ -209,9 +221,7 @@ func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, TelegramSecurity)
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
UseMarkdownV2: v.UseMarkdownV2,
|
UseMarkdownV2: v.UseMarkdownV2,
|
||||||
}, TelegramSecurity{
|
}, sec
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type feishuConfigV0 struct {
|
type feishuConfigV0 struct {
|
||||||
|
|
@ -228,7 +238,15 @@ type feishuConfigV0 struct {
|
||||||
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, FeishuSecurity) {
|
func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, *FeishuSecurity) {
|
||||||
|
var sec *FeishuSecurity
|
||||||
|
if v.AppSecret != "" || v.EncryptKey != "" || v.VerificationToken != "" {
|
||||||
|
sec = &FeishuSecurity{
|
||||||
|
AppSecret: v.AppSecret,
|
||||||
|
EncryptKey: v.EncryptKey,
|
||||||
|
VerificationToken: v.VerificationToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
return FeishuConfig{
|
return FeishuConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
AppID: v.AppID,
|
AppID: v.AppID,
|
||||||
|
|
@ -237,11 +255,7 @@ func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, FeishuSecurity) {
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, FeishuSecurity{
|
}, sec
|
||||||
AppSecret: v.AppSecret,
|
|
||||||
EncryptKey: v.EncryptKey,
|
|
||||||
VerificationToken: v.VerificationToken,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type discordConfigV0 struct {
|
type discordConfigV0 struct {
|
||||||
|
|
@ -256,7 +270,13 @@ type discordConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, DiscordSecurity) {
|
func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) {
|
||||||
|
var sec *DiscordSecurity
|
||||||
|
if v.Token != "" {
|
||||||
|
sec = &DiscordSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
return DiscordConfig{
|
return DiscordConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
token: v.Token,
|
||||||
|
|
@ -267,9 +287,7 @@ func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, DiscordSecurity) {
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, DiscordSecurity{
|
}, sec
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type maixcamConfigV0 struct {
|
type maixcamConfigV0 struct {
|
||||||
|
|
@ -299,7 +317,13 @@ type dingtalkConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, DingTalkSecurity) {
|
func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, *DingTalkSecurity) {
|
||||||
|
var sec *DingTalkSecurity
|
||||||
|
if v.ClientSecret != "" {
|
||||||
|
sec = &DingTalkSecurity{
|
||||||
|
ClientSecret: v.ClientSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
return DingTalkConfig{
|
return DingTalkConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
ClientID: v.ClientID,
|
ClientID: v.ClientID,
|
||||||
|
|
@ -307,9 +331,7 @@ func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, DingTalkSecurity)
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, DingTalkSecurity{
|
}, sec
|
||||||
ClientSecret: v.ClientSecret,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type slackConfigV0 struct {
|
type slackConfigV0 struct {
|
||||||
|
|
@ -323,7 +345,14 @@ type slackConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *slackConfigV0) ToSlackConfig() (SlackConfig, SlackSecurity) {
|
func (v *slackConfigV0) ToSlackConfig() (SlackConfig, *SlackSecurity) {
|
||||||
|
var sec *SlackSecurity
|
||||||
|
if v.BotToken != "" || v.AppToken != "" {
|
||||||
|
sec = &SlackSecurity{
|
||||||
|
BotToken: v.BotToken,
|
||||||
|
AppToken: v.AppToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
return SlackConfig{
|
return SlackConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
botToken: v.BotToken,
|
botToken: v.BotToken,
|
||||||
|
|
@ -333,10 +362,7 @@ func (v *slackConfigV0) ToSlackConfig() (SlackConfig, SlackSecurity) {
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, SlackSecurity{
|
}, sec
|
||||||
BotToken: v.BotToken,
|
|
||||||
AppToken: v.AppToken,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type matrixConfigV0 struct {
|
type matrixConfigV0 struct {
|
||||||
|
|
@ -353,7 +379,13 @@ type matrixConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, MatrixSecurity) {
|
func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) {
|
||||||
|
var sec *MatrixSecurity
|
||||||
|
if v.AccessToken != "" {
|
||||||
|
sec = &MatrixSecurity{
|
||||||
|
AccessToken: v.AccessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
return MatrixConfig{
|
return MatrixConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
Homeserver: v.Homeserver,
|
Homeserver: v.Homeserver,
|
||||||
|
|
@ -366,9 +398,7 @@ func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, MatrixSecurity) {
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, MatrixSecurity{
|
}, sec
|
||||||
AccessToken: v.AccessToken,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type lineConfigV0 struct {
|
type lineConfigV0 struct {
|
||||||
|
|
@ -385,7 +415,14 @@ type lineConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *lineConfigV0) ToLINEConfig() (LINEConfig, LINESecurity) {
|
func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) {
|
||||||
|
var sec *LINESecurity
|
||||||
|
if v.ChannelSecret != "" || v.ChannelAccessToken != "" {
|
||||||
|
sec = &LINESecurity{
|
||||||
|
ChannelSecret: v.ChannelSecret,
|
||||||
|
ChannelAccessToken: v.ChannelAccessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
return LINEConfig{
|
return LINEConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
channelSecret: v.ChannelSecret,
|
channelSecret: v.ChannelSecret,
|
||||||
|
|
@ -398,10 +435,7 @@ func (v *lineConfigV0) ToLINEConfig() (LINEConfig, LINESecurity) {
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, LINESecurity{
|
}, sec
|
||||||
ChannelSecret: v.ChannelSecret,
|
|
||||||
ChannelAccessToken: v.ChannelAccessToken,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type onebotConfigV0 struct {
|
type onebotConfigV0 struct {
|
||||||
|
|
@ -417,7 +451,13 @@ type onebotConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, OneBotSecurity) {
|
func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
|
||||||
|
var sec *OneBotSecurity
|
||||||
|
if v.AccessToken != "" {
|
||||||
|
sec = &OneBotSecurity{
|
||||||
|
AccessToken: v.AccessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
return OneBotConfig{
|
return OneBotConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
WSUrl: v.WSUrl,
|
WSUrl: v.WSUrl,
|
||||||
|
|
@ -429,9 +469,7 @@ func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, OneBotSecurity) {
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, OneBotSecurity{
|
}, sec
|
||||||
AccessToken: v.AccessToken,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomConfigV0 struct {
|
type wecomConfigV0 struct {
|
||||||
|
|
@ -448,7 +486,14 @@ type wecomConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) {
|
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, *WeComSecurity) {
|
||||||
|
var sec *WeComSecurity
|
||||||
|
if v.Token != "" || v.EncodingAESKey != "" {
|
||||||
|
sec = &WeComSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
EncodingAESKey: v.EncodingAESKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
return WeComConfig{
|
return WeComConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
token: v.Token,
|
||||||
|
|
@ -461,10 +506,7 @@ func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) {
|
||||||
ReplyTimeout: v.ReplyTimeout,
|
ReplyTimeout: v.ReplyTimeout,
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, WeComSecurity{
|
}, sec
|
||||||
Token: v.Token,
|
|
||||||
EncodingAESKey: v.EncodingAESKey,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type weixinConfigV0 struct {
|
type weixinConfigV0 struct {
|
||||||
|
|
@ -477,7 +519,13 @@ type weixinConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, WeixinSecurity) {
|
func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) {
|
||||||
|
var sec *WeixinSecurity
|
||||||
|
if v.Token != "" {
|
||||||
|
sec = &WeixinSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
return WeixinConfig{
|
return WeixinConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
token: v.Token,
|
||||||
|
|
@ -486,9 +534,7 @@ func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, WeixinSecurity) {
|
||||||
Proxy: v.Proxy,
|
Proxy: v.Proxy,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, WeixinSecurity{
|
}, sec
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomappConfigV0 struct {
|
type wecomappConfigV0 struct {
|
||||||
|
|
@ -507,7 +553,15 @@ type wecomappConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity) {
|
func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, *WeComAppSecurity) {
|
||||||
|
var sec *WeComAppSecurity
|
||||||
|
if v.CorpSecret != "" || v.Token != "" || v.EncodingAESKey != "" {
|
||||||
|
sec = &WeComAppSecurity{
|
||||||
|
CorpSecret: v.CorpSecret,
|
||||||
|
Token: v.Token,
|
||||||
|
EncodingAESKey: v.EncodingAESKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
return WeComAppConfig{
|
return WeComAppConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
CorpID: v.CorpID,
|
CorpID: v.CorpID,
|
||||||
|
|
@ -522,11 +576,7 @@ func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity)
|
||||||
ReplyTimeout: v.ReplyTimeout,
|
ReplyTimeout: v.ReplyTimeout,
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, WeComAppSecurity{
|
}, sec
|
||||||
CorpSecret: v.CorpSecret,
|
|
||||||
Token: v.Token,
|
|
||||||
EncodingAESKey: v.EncodingAESKey,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomaibotConfigV0 struct {
|
type wecomaibotConfigV0 struct {
|
||||||
|
|
@ -542,7 +592,15 @@ type wecomaibotConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotSecurity) {
|
func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, *WeComAIBotSecurity) {
|
||||||
|
var sec *WeComAIBotSecurity
|
||||||
|
if v.Token != "" || v.Secret != "" || v.EncodingAESKey != "" {
|
||||||
|
sec = &WeComAIBotSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
Secret: v.Secret,
|
||||||
|
EncodingAESKey: v.EncodingAESKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
return WeComAIBotConfig{
|
return WeComAIBotConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
WebhookPath: v.WebhookPath,
|
WebhookPath: v.WebhookPath,
|
||||||
|
|
@ -551,11 +609,7 @@ func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotS
|
||||||
MaxSteps: v.MaxSteps,
|
MaxSteps: v.MaxSteps,
|
||||||
WelcomeMessage: v.WelcomeMessage,
|
WelcomeMessage: v.WelcomeMessage,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, WeComAIBotSecurity{
|
}, sec
|
||||||
Token: v.Token,
|
|
||||||
Secret: v.Secret,
|
|
||||||
EncodingAESKey: v.EncodingAESKey,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type picoConfigV0 struct {
|
type picoConfigV0 struct {
|
||||||
|
|
@ -571,7 +625,13 @@ type picoConfigV0 struct {
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *picoConfigV0) ToPicoConfig() (PicoConfig, PicoSecurity) {
|
func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) {
|
||||||
|
var sec *PicoSecurity
|
||||||
|
if v.Token != "" {
|
||||||
|
sec = &PicoSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
return PicoConfig{
|
return PicoConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
token: v.Token,
|
||||||
|
|
@ -583,9 +643,7 @@ func (v *picoConfigV0) ToPicoConfig() (PicoConfig, PicoSecurity) {
|
||||||
MaxConnections: v.MaxConnections,
|
MaxConnections: v.MaxConnections,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
}, PicoSecurity{
|
}, sec
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ircConfigV0 struct {
|
type ircConfigV0 struct {
|
||||||
|
|
@ -607,7 +665,15 @@ type ircConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *ircConfigV0) ToIRCConfig() (IRCConfig, IRCSecurity) {
|
func (v *ircConfigV0) ToIRCConfig() (IRCConfig, *IRCSecurity) {
|
||||||
|
var sec *IRCSecurity
|
||||||
|
if v.Password != "" || v.NickServPassword != "" || v.SASLPassword != "" {
|
||||||
|
sec = &IRCSecurity{
|
||||||
|
Password: v.Password,
|
||||||
|
NickServPassword: v.NickServPassword,
|
||||||
|
SASLPassword: v.SASLPassword,
|
||||||
|
}
|
||||||
|
}
|
||||||
return IRCConfig{
|
return IRCConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
Server: v.Server,
|
Server: v.Server,
|
||||||
|
|
@ -625,11 +691,7 @@ func (v *ircConfigV0) ToIRCConfig() (IRCConfig, IRCSecurity) {
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, IRCSecurity{
|
}, sec
|
||||||
Password: v.Password,
|
|
||||||
NickServPassword: v.NickServPassword,
|
|
||||||
SASLPassword: v.SASLPassword,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type providersConfigV0 struct {
|
type providersConfigV0 struct {
|
||||||
|
|
@ -783,7 +845,7 @@ func (c *configV0) Migrate() (*Config, error) {
|
||||||
cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig()
|
cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig()
|
||||||
cfg.Tools.Cron = c.Tools.Cron
|
cfg.Tools.Cron = c.Tools.Cron
|
||||||
cfg.Tools.Exec = c.Tools.Exec
|
cfg.Tools.Exec = c.Tools.Exec
|
||||||
var secSkills SkillsSecurity
|
var secSkills *SkillsSecurity
|
||||||
cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig()
|
cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig()
|
||||||
cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
|
cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
|
||||||
cfg.Tools.MCP = c.Tools.MCP
|
cfg.Tools.MCP = c.Tools.MCP
|
||||||
|
|
@ -835,16 +897,18 @@ func (c *configV0) Migrate() (*Config, error) {
|
||||||
for i, m := range c.ModelList {
|
for i, m := range c.ModelList {
|
||||||
// Merge APIKey and APIKeys, deduplicating
|
// Merge APIKey and APIKeys, deduplicating
|
||||||
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
|
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
|
||||||
|
if len(mergedKeys) > 0 {
|
||||||
secModels[names[i]] = ModelSecurityEntry{
|
secModels[names[i]] = ModelSecurityEntry{
|
||||||
APIKeys: mergedKeys,
|
APIKeys: mergedKeys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg.WithSecurity(&SecurityConfig{
|
cfg.WithSecurity(&SecurityConfig{
|
||||||
ModelList: secModels,
|
ModelList: secModels,
|
||||||
Channels: secChannels,
|
Channels: &secChannels,
|
||||||
Web: secWeb,
|
Web: &secWeb,
|
||||||
Skills: secSkills,
|
Skills: secSkills,
|
||||||
})
|
})
|
||||||
cfg.Version = CurrentVersion
|
cfg.Version = CurrentVersion
|
||||||
|
|
@ -873,13 +937,17 @@ type braveConfigV0 struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *braveConfigV0) ToBraveConfig() (BraveConfig, BraveSecurity) {
|
func (v *braveConfigV0) ToBraveConfig() (BraveConfig, *BraveSecurity) {
|
||||||
|
var sec *BraveSecurity
|
||||||
|
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
||||||
|
sec = &BraveSecurity{
|
||||||
|
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
||||||
|
}
|
||||||
|
}
|
||||||
return BraveConfig{
|
return BraveConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
MaxResults: v.MaxResults,
|
MaxResults: v.MaxResults,
|
||||||
}, BraveSecurity{
|
}, sec
|
||||||
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type tavilyConfigV0 struct {
|
type tavilyConfigV0 struct {
|
||||||
|
|
@ -890,14 +958,18 @@ type tavilyConfigV0 struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, TavilySecurity) {
|
func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, *TavilySecurity) {
|
||||||
|
var sec *TavilySecurity
|
||||||
|
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
||||||
|
sec = &TavilySecurity{
|
||||||
|
APIKeys: k,
|
||||||
|
}
|
||||||
|
}
|
||||||
return TavilyConfig{
|
return TavilyConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
MaxResults: v.MaxResults,
|
MaxResults: v.MaxResults,
|
||||||
}, TavilySecurity{
|
}, sec
|
||||||
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type perplexityConfigV0 struct {
|
type perplexityConfigV0 struct {
|
||||||
|
|
@ -907,13 +979,17 @@ type perplexityConfigV0 struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, PerplexitySecurity) {
|
func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, *PerplexitySecurity) {
|
||||||
|
var sec *PerplexitySecurity
|
||||||
|
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
||||||
|
sec = &PerplexitySecurity{
|
||||||
|
APIKeys: k,
|
||||||
|
}
|
||||||
|
}
|
||||||
return PerplexityConfig{
|
return PerplexityConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
MaxResults: v.MaxResults,
|
MaxResults: v.MaxResults,
|
||||||
}, PerplexitySecurity{
|
}, sec
|
||||||
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type glmSearchConfigV0 struct {
|
type glmSearchConfigV0 struct {
|
||||||
|
|
@ -923,15 +999,19 @@ type glmSearchConfigV0 struct {
|
||||||
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, GLMSearchSecurity) {
|
func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecurity) {
|
||||||
|
var sec *GLMSearchSecurity
|
||||||
|
if v.APIKey != "" {
|
||||||
|
sec = &GLMSearchSecurity{
|
||||||
|
APIKey: v.APIKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
return GLMSearchConfig{
|
return GLMSearchConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
apiKey: v.APIKey,
|
apiKey: v.APIKey,
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
SearchEngine: v.SearchEngine,
|
SearchEngine: v.SearchEngine,
|
||||||
}, GLMSearchSecurity{
|
}, sec
|
||||||
APIKey: v.APIKey,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
|
func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
|
||||||
|
|
@ -954,10 +1034,10 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity)
|
||||||
Format: v.Format,
|
Format: v.Format,
|
||||||
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
||||||
}, WebToolsSecurity{
|
}, WebToolsSecurity{
|
||||||
Brave: &braveSecurity,
|
Brave: braveSecurity,
|
||||||
Tavily: &tavilySecurity,
|
Tavily: tavilySecurity,
|
||||||
Perplexity: &perplexitySecurity,
|
Perplexity: perplexitySecurity,
|
||||||
GLMSearch: &glmSearchSecurity,
|
GLMSearch: glmSearchSecurity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -981,16 +1061,20 @@ type clawHubRegistryConfigV0 struct {
|
||||||
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, ClawHubSecurity) {
|
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, *ClawHubSecurity) {
|
||||||
|
var sec *ClawHubSecurity
|
||||||
|
if v.AuthToken != "" {
|
||||||
|
sec = &ClawHubSecurity{
|
||||||
|
AuthToken: v.AuthToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
return ClawHubRegistryConfig{
|
return ClawHubRegistryConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
authToken: v.AuthToken,
|
authToken: v.AuthToken,
|
||||||
SearchPath: v.SearchPath,
|
SearchPath: v.SearchPath,
|
||||||
SkillsPath: v.SkillsPath,
|
SkillsPath: v.SkillsPath,
|
||||||
}, ClawHubSecurity{
|
}, sec
|
||||||
AuthToken: v.AuthToken,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type skillsGithubConfigV0 struct {
|
type skillsGithubConfigV0 struct {
|
||||||
|
|
@ -998,13 +1082,17 @@ type skillsGithubConfigV0 struct {
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, GithubSecurity) {
|
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, *GithubSecurity) {
|
||||||
|
var sec *GithubSecurity
|
||||||
|
if v.Token != "" {
|
||||||
|
sec = &GithubSecurity{
|
||||||
|
Token: v.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
return SkillsGithubConfig{
|
return SkillsGithubConfig{
|
||||||
token: v.Token,
|
token: v.Token,
|
||||||
Proxy: v.Proxy,
|
Proxy: v.Proxy,
|
||||||
}, GithubSecurity{
|
}, sec
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) {
|
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) {
|
||||||
|
|
@ -1012,21 +1100,25 @@ func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesC
|
||||||
|
|
||||||
return SkillsRegistriesConfig{
|
return SkillsRegistriesConfig{
|
||||||
ClawHub: clawHub,
|
ClawHub: clawHub,
|
||||||
}, &clawHubSecurity
|
}, clawHubSecurity
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, SkillsSecurity) {
|
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, *SkillsSecurity) {
|
||||||
registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig()
|
registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig()
|
||||||
github, githubSecurity := v.Github.ToSkillsGithubConfig()
|
github, githubSecurity := v.Github.ToSkillsGithubConfig()
|
||||||
|
|
||||||
|
var sec *SkillsSecurity
|
||||||
|
if githubSecurity != nil || registriesSecurity != nil {
|
||||||
|
sec = &SkillsSecurity{
|
||||||
|
Github: githubSecurity,
|
||||||
|
ClawHub: registriesSecurity,
|
||||||
|
}
|
||||||
|
}
|
||||||
return SkillsToolsConfig{
|
return SkillsToolsConfig{
|
||||||
ToolConfig: v.ToolConfig,
|
ToolConfig: v.ToolConfig,
|
||||||
Registries: registries,
|
Registries: registries,
|
||||||
Github: github,
|
Github: github,
|
||||||
MaxConcurrentSearches: v.MaxConcurrentSearches,
|
MaxConcurrentSearches: v.MaxConcurrentSearches,
|
||||||
SearchCache: v.SearchCache,
|
SearchCache: v.SearchCache,
|
||||||
}, SkillsSecurity{
|
}, sec
|
||||||
Github: &githubSecurity,
|
|
||||||
ClawHub: registriesSecurity,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,40 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if !cfg.Tools.FilterSensitiveData {
|
||||||
|
t.Fatal("DefaultConfig().Tools.FilterSensitiveData should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_FilterMinLength(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if cfg.Tools.FilterMinLength != 8 {
|
||||||
|
t.Fatalf("DefaultConfig().Tools.FilterMinLength = %d, want 8", cfg.Tools.FilterMinLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsConfig_GetFilterMinLength(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
minLen int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"zero returns default", 0, 8},
|
||||||
|
{"negative returns default", -1, 8},
|
||||||
|
{"positive returns value", 16, 16},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &ToolsConfig{FilterMinLength: tt.minLen}
|
||||||
|
if got := cfg.GetFilterMinLength(); got != tt.expected {
|
||||||
|
t.Errorf("GetFilterMinLength() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
if !cfg.Tools.Cron.AllowCommand {
|
if !cfg.Tools.Cron.AllowCommand {
|
||||||
|
|
@ -1252,3 +1286,183 @@ func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
|
||||||
t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got)
|
t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterSensitiveData(t *testing.T) {
|
||||||
|
// Test with nil security config
|
||||||
|
cfg := &Config{}
|
||||||
|
if got := cfg.FilterSensitiveData("hello sk-key123 world"); got != "hello sk-key123 world" {
|
||||||
|
t.Errorf("nil security: got %q, want original", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with empty content
|
||||||
|
cfg.security = &SecurityConfig{}
|
||||||
|
if got := cfg.FilterSensitiveData(""); got != "" {
|
||||||
|
t.Errorf("empty content: got %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test short content (less than FilterMinLength=8, should skip filtering)
|
||||||
|
cfg.security.ModelList = map[string]ModelSecurityEntry{
|
||||||
|
"test": {APIKeys: []string{"sk-long-key-12345"}},
|
||||||
|
}
|
||||||
|
cfg.Tools.FilterSensitiveData = true
|
||||||
|
cfg.Tools.FilterMinLength = 8
|
||||||
|
|
||||||
|
// Debug: check if sensitive values are collected
|
||||||
|
values := cfg.security.collectSensitiveValues()
|
||||||
|
t.Logf("collected %d sensitive values: %v", len(values), values)
|
||||||
|
|
||||||
|
if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" {
|
||||||
|
t.Errorf("short content should not be filtered: got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test filtering works
|
||||||
|
content := "Your API key is sk-long-key-12345 and token abc123"
|
||||||
|
// abc123 is not in sensitive values, only sk-long-key-12345 should be filtered
|
||||||
|
expected := "Your API key is [FILTERED] and token abc123"
|
||||||
|
if got := cfg.FilterSensitiveData(content); got != expected {
|
||||||
|
t.Errorf("filtering failed: got %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test disabled filtering
|
||||||
|
cfg.Tools.FilterSensitiveData = false
|
||||||
|
if got := cfg.FilterSensitiveData(content); got != content {
|
||||||
|
t.Errorf("disabled filtering: got %q, want original %q", got, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterSensitiveData_MultipleKeys(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Tools: ToolsConfig{
|
||||||
|
FilterSensitiveData: true,
|
||||||
|
FilterMinLength: 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.security = &SecurityConfig{
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"model1": {APIKeys: []string{"key-one", "key-two"}},
|
||||||
|
"model2": {APIKeys: []string{"key-three"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content := "key-one and key-two and key-three should be filtered"
|
||||||
|
expected := "[FILTERED] and [FILTERED] and [FILTERED] should be filtered"
|
||||||
|
if got := cfg.FilterSensitiveData(content); got != expected {
|
||||||
|
t.Errorf("multiple keys: got %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Tools: ToolsConfig{
|
||||||
|
FilterSensitiveData: true,
|
||||||
|
FilterMinLength: 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.security = &SecurityConfig{
|
||||||
|
// Model API keys
|
||||||
|
ModelList: map[string]ModelSecurityEntry{
|
||||||
|
"test-model": {APIKeys: []string{"sk-model-key-12345"}},
|
||||||
|
},
|
||||||
|
// Channel tokens
|
||||||
|
Channels: &ChannelsSecurity{
|
||||||
|
Telegram: &TelegramSecurity{Token: "telegram-bot-token-abcdef"},
|
||||||
|
Discord: &DiscordSecurity{Token: "discord-bot-token-xyz789"},
|
||||||
|
Slack: &SlackSecurity{BotToken: "xoxb-slack-bot-token", AppToken: "xapp-slack-app-token"},
|
||||||
|
Matrix: &MatrixSecurity{AccessToken: "matrix-access-token-abc"},
|
||||||
|
Feishu: &FeishuSecurity{AppSecret: "feishu-app-secret-123", EncryptKey: "feishu-encrypt-key"},
|
||||||
|
DingTalk: &DingTalkSecurity{ClientSecret: "dingtalk-client-secret"},
|
||||||
|
OneBot: &OneBotSecurity{AccessToken: "onebot-access-token"},
|
||||||
|
WeCom: &WeComSecurity{Token: "wecom-token", EncodingAESKey: "wecom-aes-key"},
|
||||||
|
WeComApp: &WeComAppSecurity{CorpSecret: "wecom-app-secret", Token: "wecom-app-token"},
|
||||||
|
Pico: &PicoSecurity{Token: "pico-token-abc123"},
|
||||||
|
IRC: &IRCSecurity{
|
||||||
|
Password: "irc-password",
|
||||||
|
NickServPassword: "nickserv-pass",
|
||||||
|
SASLPassword: "sasl-pass",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Web tool API keys
|
||||||
|
Web: &WebToolsSecurity{
|
||||||
|
Brave: &BraveSecurity{APIKeys: []string{"brave-api-key"}},
|
||||||
|
Tavily: &TavilySecurity{APIKeys: []string{"tavily-api-key"}},
|
||||||
|
Perplexity: &PerplexitySecurity{APIKeys: []string{"perplexity-api-key"}},
|
||||||
|
GLMSearch: &GLMSearchSecurity{APIKey: "glm-search-key"},
|
||||||
|
BaiduSearch: &BaiduSearchSecurity{APIKey: "baidu-search-key"},
|
||||||
|
},
|
||||||
|
// Skills tokens
|
||||||
|
Skills: &SkillsSecurity{
|
||||||
|
Github: &GithubSecurity{Token: "github-token-xyz"},
|
||||||
|
ClawHub: &ClawHubSecurity{AuthToken: "clawhub-auth-token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "model_api_key",
|
||||||
|
content: "Using model with key sk-model-key-12345",
|
||||||
|
want: "Using model with key [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "telegram_token",
|
||||||
|
content: "Telegram token: telegram-bot-token-abcdef",
|
||||||
|
want: "Telegram token: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discord_token",
|
||||||
|
content: "Discord token: discord-bot-token-xyz789",
|
||||||
|
want: "Discord token: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "slack_tokens",
|
||||||
|
content: "Slack bot: xoxb-slack-bot-token, app: xapp-slack-app-token",
|
||||||
|
want: "Slack bot: [FILTERED], app: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matrix_token",
|
||||||
|
content: "Matrix access token: matrix-access-token-abc",
|
||||||
|
want: "Matrix access token: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "brave_api_key",
|
||||||
|
content: "Brave key: brave-api-key",
|
||||||
|
want: "Brave key: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tavily_api_key",
|
||||||
|
content: "Tavily key: tavily-api-key",
|
||||||
|
want: "Tavily key: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "github_token",
|
||||||
|
content: "GitHub token: github-token-xyz",
|
||||||
|
want: "GitHub token: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "irc_passwords",
|
||||||
|
content: "IRC password: irc-password, nickserv: nickserv-pass",
|
||||||
|
want: "IRC password: [FILTERED], nickserv: [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_content",
|
||||||
|
content: "Model key sk-model-key-12345 and Telegram token telegram-bot-token-abcdef",
|
||||||
|
want: "Model key [FILTERED] and Telegram token [FILTERED]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short_key_not_filtered",
|
||||||
|
content: "Key abc not filtered because length < 8",
|
||||||
|
want: "Key abc not filtered because length < 8",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := cfg.FilterSensitiveData(tt.content); got != tt.want {
|
||||||
|
t.Errorf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,8 @@ func DefaultConfig() *Config {
|
||||||
LogLevel: "fatal",
|
LogLevel: "fatal",
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
|
FilterSensitiveData: true,
|
||||||
|
FilterMinLength: 8,
|
||||||
MediaCleanup: MediaCleanupConfig{
|
MediaCleanup: MediaCleanupConfig{
|
||||||
ToolConfig: ToolConfig{
|
ToolConfig: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -537,8 +539,9 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
security: &SecurityConfig{
|
security: &SecurityConfig{
|
||||||
ModelList: map[string]ModelSecurityEntry{},
|
ModelList: map[string]ModelSecurityEntry{},
|
||||||
Channels: ChannelsSecurity{},
|
Channels: &ChannelsSecurity{},
|
||||||
Web: WebToolsSecurity{},
|
Web: &WebToolsSecurity{},
|
||||||
|
Skills: &SkillsSecurity{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -566,3 +566,118 @@ func TestMigration_Integration_ModelNameField(t *testing.T) {
|
||||||
t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
|
t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMigration_PreservesExistingSecurityConfig tests that when migrating from v0 to v1,
|
||||||
|
// existing .security.yml values (e.g., loaded from environment variables) are preserved
|
||||||
|
// and not overwritten by empty values from the legacy config.
|
||||||
|
func TestMigration_PreservesExistingSecurityConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
securityPath := filepath.Join(tmpDir, ".security.yml")
|
||||||
|
|
||||||
|
// Create a legacy config (version 0) with model_list and channel config
|
||||||
|
// The model_list doesn't have api_keys, they should come from existing .security.yml
|
||||||
|
legacyConfig := `{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "openai",
|
||||||
|
"model": "openai/gpt-4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gateway": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 18790
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {"enabled": true}
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval": 30
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Create an existing .security.yml with values that might come from env vars
|
||||||
|
existingSecurity := `model_list:
|
||||||
|
openai:0:
|
||||||
|
api_keys:
|
||||||
|
- sk-existing-key-from-env
|
||||||
|
channels:
|
||||||
|
telegram:
|
||||||
|
token: existing-telegram-token-from-env
|
||||||
|
discord:
|
||||||
|
token: existing-discord-token-from-env
|
||||||
|
web:
|
||||||
|
brave:
|
||||||
|
api_keys:
|
||||||
|
- existing-brave-key
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
|
||||||
|
t.Fatalf("Failed to write legacy config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(securityPath, []byte(existingSecurity), 0o600); err != nil {
|
||||||
|
t.Fatalf("Failed to write existing security config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the config - this should trigger migration
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the migrated config has the existing security values
|
||||||
|
// Telegram token should be preserved
|
||||||
|
if cfg.Channels.Telegram.Token() != "existing-telegram-token-from-env" {
|
||||||
|
t.Errorf("Telegram token was overwritten: got %q, want %q",
|
||||||
|
cfg.Channels.Telegram.Token(), "existing-telegram-token-from-env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord token should be preserved (even though legacy config didn't have it)
|
||||||
|
if cfg.Channels.Discord.Token() != "existing-discord-token-from-env" {
|
||||||
|
t.Errorf("Discord token was overwritten: got %q, want %q",
|
||||||
|
cfg.Channels.Discord.Token(), "existing-discord-token-from-env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model API key should be preserved
|
||||||
|
if cfg.ModelList[0].APIKey() != "sk-existing-key-from-env" {
|
||||||
|
t.Errorf("Model API key was overwritten: got %q, want %q",
|
||||||
|
cfg.ModelList[0].APIKey(), "sk-existing-key-from-env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brave API key should be preserved
|
||||||
|
if cfg.Tools.Web.Brave.APIKey() != "existing-brave-key" {
|
||||||
|
t.Errorf("Brave API key was overwritten: got %q, want %q",
|
||||||
|
cfg.Tools.Web.Brave.APIKey(), "existing-brave-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the security config from disk to verify it wasn't corrupted
|
||||||
|
reloadedSec, err := loadSecurityConfig(securityPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to reload security config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reloadedSec.Channels.Telegram == nil ||
|
||||||
|
reloadedSec.Channels.Telegram.Token != "existing-telegram-token-from-env" {
|
||||||
|
t.Error("Telegram token not preserved in .security.yml file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if reloadedSec.Channels.Discord == nil || reloadedSec.Channels.Discord.Token != "existing-discord-token-from-env" {
|
||||||
|
t.Error("Discord token not preserved in .security.yml file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
"github.com/tencent-connect/botgo/log"
|
"github.com/tencent-connect/botgo/log"
|
||||||
|
|
@ -22,19 +25,41 @@ const (
|
||||||
SecurityConfigFile = ".security.yml"
|
SecurityConfigFile = ".security.yml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func normalizeSecurityConfig(sec *SecurityConfig) *SecurityConfig {
|
||||||
|
if sec == nil {
|
||||||
|
sec = &SecurityConfig{}
|
||||||
|
}
|
||||||
|
if sec.ModelList == nil {
|
||||||
|
sec.ModelList = map[string]ModelSecurityEntry{}
|
||||||
|
}
|
||||||
|
if sec.Channels == nil {
|
||||||
|
sec.Channels = &ChannelsSecurity{}
|
||||||
|
}
|
||||||
|
if sec.Web == nil {
|
||||||
|
sec.Web = &WebToolsSecurity{}
|
||||||
|
}
|
||||||
|
if sec.Skills == nil {
|
||||||
|
sec.Skills = &SkillsSecurity{}
|
||||||
|
}
|
||||||
|
return sec
|
||||||
|
}
|
||||||
|
|
||||||
// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords)
|
// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords)
|
||||||
// This data is loaded from security.yml and kept separate from the main config
|
// This data is loaded from security.yml and kept separate from the main config
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
// Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1"
|
// Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1"
|
||||||
// for load balancing with same model_name. The suffix ":N" is used to distinguish
|
// for load balancing with same model_name. The suffix ":N" is used to distinguish
|
||||||
// multiple configs that share the same base model_name.
|
// multiple configs that share the same base model_name.
|
||||||
ModelList map[string]ModelSecurityEntry `yaml:"model_list,omitempty"`
|
ModelList map[string]ModelSecurityEntry `yaml:"model_list"`
|
||||||
|
|
||||||
// Channel tokens/secrets
|
// Channel tokens/secrets
|
||||||
Channels ChannelsSecurity `yaml:"channels,omitempty"`
|
Channels *ChannelsSecurity `yaml:"channels,omitempty"`
|
||||||
|
|
||||||
Web WebToolsSecurity `yaml:"web,omitempty"`
|
Web *WebToolsSecurity `yaml:"web,omitempty"`
|
||||||
Skills SkillsSecurity `yaml:"skills,omitempty"`
|
Skills *SkillsSecurity `yaml:"skills,omitempty"`
|
||||||
|
|
||||||
|
// cache for sensitive values and compiled regex (computed once)
|
||||||
|
sensitiveCache *SensitiveDataCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelSecurityEntry stores security data for a model
|
// ModelSecurityEntry stores security data for a model
|
||||||
|
|
@ -185,7 +210,7 @@ func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
|
||||||
data, err := os.ReadFile(securityPath)
|
data, err := os.ReadFile(securityPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return &SecurityConfig{}, nil
|
return normalizeSecurityConfig(nil), nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to read security config: %w", err)
|
return nil, fmt.Errorf("failed to read security config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +229,7 @@ func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &sec, nil
|
return normalizeSecurityConfig(&sec), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSecurityConfig saves the security configuration to security.yml
|
// saveSecurityConfig saves the security configuration to security.yml
|
||||||
|
|
@ -218,3 +243,227 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
|
||||||
}
|
}
|
||||||
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'.
|
||||||
|
// This is used during config migration to preserve existing security data while adding new entries.
|
||||||
|
func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig {
|
||||||
|
if existing == nil {
|
||||||
|
return normalizeSecurityConfig(newer)
|
||||||
|
}
|
||||||
|
if newer == nil {
|
||||||
|
return normalizeSecurityConfig(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := normalizeSecurityConfig(nil)
|
||||||
|
|
||||||
|
// Merge ModelList: prefer newer if it has keys, otherwise use existing
|
||||||
|
for k, v := range existing.ModelList {
|
||||||
|
result.ModelList[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range newer.ModelList {
|
||||||
|
if len(v.APIKeys) > 0 {
|
||||||
|
result.ModelList[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge Channels
|
||||||
|
if existing.Channels != nil {
|
||||||
|
result.Channels = existing.Channels
|
||||||
|
}
|
||||||
|
if newer.Channels != nil {
|
||||||
|
if result.Channels == nil {
|
||||||
|
result.Channels = &ChannelsSecurity{}
|
||||||
|
}
|
||||||
|
mergeChannelsSecurity(result.Channels, newer.Channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge Web
|
||||||
|
if existing.Web != nil {
|
||||||
|
result.Web = existing.Web
|
||||||
|
}
|
||||||
|
if newer.Web != nil {
|
||||||
|
if result.Web == nil {
|
||||||
|
result.Web = &WebToolsSecurity{}
|
||||||
|
}
|
||||||
|
mergeWebToolsSecurity(result.Web, newer.Web)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge Skills
|
||||||
|
if existing.Skills != nil {
|
||||||
|
result.Skills = existing.Skills
|
||||||
|
}
|
||||||
|
if newer.Skills != nil {
|
||||||
|
if result.Skills == nil {
|
||||||
|
result.Skills = &SkillsSecurity{}
|
||||||
|
}
|
||||||
|
mergeSkillsSecurity(result.Skills, newer.Skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeChannelsSecurity(dst, src *ChannelsSecurity) {
|
||||||
|
if src.Telegram != nil && src.Telegram.Token != "" {
|
||||||
|
dst.Telegram = src.Telegram
|
||||||
|
}
|
||||||
|
if src.Feishu != nil &&
|
||||||
|
(src.Feishu.AppSecret != "" || src.Feishu.EncryptKey != "" || src.Feishu.VerificationToken != "") {
|
||||||
|
dst.Feishu = src.Feishu
|
||||||
|
}
|
||||||
|
if src.Discord != nil && src.Discord.Token != "" {
|
||||||
|
dst.Discord = src.Discord
|
||||||
|
}
|
||||||
|
if src.Weixin != nil && src.Weixin.Token != "" {
|
||||||
|
dst.Weixin = src.Weixin
|
||||||
|
}
|
||||||
|
if src.QQ != nil && src.QQ.AppSecret != "" {
|
||||||
|
dst.QQ = src.QQ
|
||||||
|
}
|
||||||
|
if src.DingTalk != nil && src.DingTalk.ClientSecret != "" {
|
||||||
|
dst.DingTalk = src.DingTalk
|
||||||
|
}
|
||||||
|
if src.Slack != nil && (src.Slack.BotToken != "" || src.Slack.AppToken != "") {
|
||||||
|
dst.Slack = src.Slack
|
||||||
|
}
|
||||||
|
if src.Matrix != nil && src.Matrix.AccessToken != "" {
|
||||||
|
dst.Matrix = src.Matrix
|
||||||
|
}
|
||||||
|
if src.LINE != nil && (src.LINE.ChannelSecret != "" || src.LINE.ChannelAccessToken != "") {
|
||||||
|
dst.LINE = src.LINE
|
||||||
|
}
|
||||||
|
if src.OneBot != nil && src.OneBot.AccessToken != "" {
|
||||||
|
dst.OneBot = src.OneBot
|
||||||
|
}
|
||||||
|
if src.WeCom != nil && (src.WeCom.Token != "" || src.WeCom.EncodingAESKey != "") {
|
||||||
|
dst.WeCom = src.WeCom
|
||||||
|
}
|
||||||
|
if src.WeComApp != nil &&
|
||||||
|
(src.WeComApp.CorpSecret != "" || src.WeComApp.Token != "" || src.WeComApp.EncodingAESKey != "") {
|
||||||
|
dst.WeComApp = src.WeComApp
|
||||||
|
}
|
||||||
|
if src.WeComAIBot != nil &&
|
||||||
|
(src.WeComAIBot.Secret != "" || src.WeComAIBot.Token != "" || src.WeComAIBot.EncodingAESKey != "") {
|
||||||
|
dst.WeComAIBot = src.WeComAIBot
|
||||||
|
}
|
||||||
|
if src.Pico != nil && src.Pico.Token != "" {
|
||||||
|
dst.Pico = src.Pico
|
||||||
|
}
|
||||||
|
if src.IRC != nil && (src.IRC.Password != "" || src.IRC.NickServPassword != "" || src.IRC.SASLPassword != "") {
|
||||||
|
dst.IRC = src.IRC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeWebToolsSecurity(dst, src *WebToolsSecurity) {
|
||||||
|
if src.Brave != nil && len(src.Brave.APIKeys) > 0 {
|
||||||
|
dst.Brave = src.Brave
|
||||||
|
}
|
||||||
|
if src.Tavily != nil && len(src.Tavily.APIKeys) > 0 {
|
||||||
|
dst.Tavily = src.Tavily
|
||||||
|
}
|
||||||
|
if src.Perplexity != nil && len(src.Perplexity.APIKeys) > 0 {
|
||||||
|
dst.Perplexity = src.Perplexity
|
||||||
|
}
|
||||||
|
if src.GLMSearch != nil && src.GLMSearch.APIKey != "" {
|
||||||
|
dst.GLMSearch = src.GLMSearch
|
||||||
|
}
|
||||||
|
if src.BaiduSearch != nil && src.BaiduSearch.APIKey != "" {
|
||||||
|
dst.BaiduSearch = src.BaiduSearch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeSkillsSecurity(dst, src *SkillsSecurity) {
|
||||||
|
if src.Github != nil && src.Github.Token != "" {
|
||||||
|
dst.Github = src.Github
|
||||||
|
}
|
||||||
|
if src.ClawHub != nil && src.ClawHub.AuthToken != "" {
|
||||||
|
dst.ClawHub = src.ClawHub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
|
||||||
|
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
||||||
|
// Computed once on first access via sync.Once.
|
||||||
|
type SensitiveDataCache struct {
|
||||||
|
replacer *strings.Replacer
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data.
|
||||||
|
// It is computed once on first access via sync.Once.
|
||||||
|
func (sec *SecurityConfig) SensitiveDataReplacer() *strings.Replacer {
|
||||||
|
sec.initSensitiveCache()
|
||||||
|
return sec.sensitiveCache.replacer
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSensitiveCache initializes the sensitive data cache if not already done.
|
||||||
|
func (sec *SecurityConfig) initSensitiveCache() {
|
||||||
|
if sec.sensitiveCache == nil {
|
||||||
|
sec.sensitiveCache = &SensitiveDataCache{}
|
||||||
|
}
|
||||||
|
sec.sensitiveCache.once.Do(func() {
|
||||||
|
values := sec.collectSensitiveValues()
|
||||||
|
if len(values) == 0 {
|
||||||
|
sec.sensitiveCache.replacer = strings.NewReplacer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build old/new pairs for strings.Replacer
|
||||||
|
var pairs []string
|
||||||
|
for _, v := range values {
|
||||||
|
if len(v) > 3 {
|
||||||
|
pairs = append(pairs, v, "[FILTERED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
sec.sensitiveCache.replacer = strings.NewReplacer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sec.sensitiveCache.replacer = strings.NewReplacer(pairs...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection.
|
||||||
|
func (sec *SecurityConfig) collectSensitiveValues() []string {
|
||||||
|
var values []string
|
||||||
|
collectSensitive(reflect.ValueOf(sec), &values)
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectSensitive recursively traverses the value and collects all non-empty string fields.
|
||||||
|
func collectSensitive(v reflect.Value, values *[]string) {
|
||||||
|
// Dereference pointers/interfaces to get the underlying value
|
||||||
|
for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
|
||||||
|
if v.IsNil() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Struct:
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
field := v.Field(i)
|
||||||
|
fieldType := v.Type().Field(i)
|
||||||
|
if !fieldType.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
collectSensitive(field, values)
|
||||||
|
}
|
||||||
|
case reflect.String:
|
||||||
|
if v.String() != "" {
|
||||||
|
*values = append(*values, v.String())
|
||||||
|
}
|
||||||
|
case reflect.Slice:
|
||||||
|
if v.Type().Elem().Kind() == reflect.String {
|
||||||
|
for i := 0; i < v.Len(); i++ {
|
||||||
|
if s := v.Index(i).String(); s != "" {
|
||||||
|
*values = append(*values, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case reflect.Map:
|
||||||
|
for _, key := range v.MapKeys() {
|
||||||
|
collectSensitive(v.MapIndex(key), values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,12 @@ import (
|
||||||
|
|
||||||
// Test JSON unmarshal of private fields
|
// Test JSON unmarshal of private fields
|
||||||
func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
||||||
//nolint: govet
|
|
||||||
type testStruct struct {
|
type testStruct struct {
|
||||||
PublicField string `json:"public"`
|
PublicField string `json:"public"`
|
||||||
privateField string `json:"private"`
|
privateField string
|
||||||
}
|
}
|
||||||
|
|
||||||
data := `{"public": "pub", "private": "priv"}`
|
data := `{"public": "pub", "privateField": "priv"}`
|
||||||
var s testStruct
|
var s testStruct
|
||||||
if err := json.Unmarshal([]byte(data), &s); err != nil {
|
if err := json.Unmarshal([]byte(data), &s); err != nil {
|
||||||
t.Fatalf("JSON unmarshal failed: %v", err)
|
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||||
|
|
@ -35,9 +34,8 @@ func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
||||||
if s.PublicField != "pub" {
|
if s.PublicField != "pub" {
|
||||||
t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
|
t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
|
||||||
}
|
}
|
||||||
// This should fail because privateField is unexported
|
if s.privateField != "" {
|
||||||
if s.privateField != "priv" {
|
t.Errorf("privateField = %q, want empty because unexported fields are ignored", s.privateField)
|
||||||
t.Logf("privateField = %q, want 'priv' - THIS IS EXPECTED TO FAIL", s.privateField)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ func TestSecurityConfig(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, sec)
|
assert.NotNil(t, sec)
|
||||||
assert.Empty(t, sec.ModelList)
|
assert.Empty(t, sec.ModelList)
|
||||||
|
assert.NotNil(t, sec.Channels)
|
||||||
|
assert.NotNil(t, sec.Web)
|
||||||
|
assert.NotNil(t, sec.Skills)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,12 +62,12 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) {
|
||||||
APIKeys: []string{"key1", "key2"},
|
APIKeys: []string{"key1", "key2"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Channels: ChannelsSecurity{
|
Channels: &ChannelsSecurity{
|
||||||
Telegram: &TelegramSecurity{
|
Telegram: &TelegramSecurity{
|
||||||
Token: "telegram-token",
|
Token: "telegram-token",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Web: WebToolsSecurity{
|
Web: &WebToolsSecurity{
|
||||||
Brave: &BraveSecurity{
|
Brave: &BraveSecurity{
|
||||||
APIKeys: []string{"brave-api-key"},
|
APIKeys: []string{"brave-api-key"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -117,3 +117,11 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
cleanup = false
|
cleanup = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CopyFile(src, dst string, perm os.FileMode) error {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return WriteFileAtomic(dst, data, perm)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func initPanicFile(panicFile string) io.WriteCloser {
|
func initPanicFile(panicFile string) io.WriteCloser {
|
||||||
file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_SYNC|os.O_APPEND, 0600)
|
file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_SYNC|os.O_APPEND, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("error in open panic: %v", err))
|
panic(fmt.Sprintf("error in open panic: %v", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
582
pkg/providers/bedrock/provider_bedrock.go
Normal file
582
pkg/providers/bedrock/provider_bedrock.go
Normal file
|
|
@ -0,0 +1,582 @@
|
||||||
|
//go:build bedrock
|
||||||
|
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
// Package bedrock implements the LLM provider interface for AWS Bedrock.
|
||||||
|
// It uses the Bedrock Runtime Converse API for unified access to multiple
|
||||||
|
// model families (Claude, Llama, Mistral, etc.) with tool/function calling support.
|
||||||
|
package bedrock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
ToolCall = protocoltypes.ToolCall
|
||||||
|
FunctionCall = protocoltypes.FunctionCall
|
||||||
|
LLMResponse = protocoltypes.LLMResponse
|
||||||
|
UsageInfo = protocoltypes.UsageInfo
|
||||||
|
Message = protocoltypes.Message
|
||||||
|
ToolDefinition = protocoltypes.ToolDefinition
|
||||||
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider implements the LLM provider interface for AWS Bedrock.
|
||||||
|
type Provider struct {
|
||||||
|
client *bedrockruntime.Client
|
||||||
|
region string
|
||||||
|
requestTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures the Bedrock Provider.
|
||||||
|
type Option func(*providerConfig)
|
||||||
|
|
||||||
|
type providerConfig struct {
|
||||||
|
region string
|
||||||
|
profile string
|
||||||
|
baseEndpoint string
|
||||||
|
requestTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRegion sets the AWS region for Bedrock requests.
|
||||||
|
func WithRegion(region string) Option {
|
||||||
|
return func(c *providerConfig) {
|
||||||
|
c.region = region
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfile sets the AWS profile to use for credentials.
|
||||||
|
func WithProfile(profile string) Option {
|
||||||
|
return func(c *providerConfig) {
|
||||||
|
c.profile = profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBaseEndpoint sets a custom Bedrock endpoint URL.
|
||||||
|
// Example: https://bedrock-runtime.us-east-1.amazonaws.com
|
||||||
|
func WithBaseEndpoint(endpoint string) Option {
|
||||||
|
return func(c *providerConfig) {
|
||||||
|
c.baseEndpoint = endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRequestTimeout sets the timeout for Bedrock API requests.
|
||||||
|
func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
|
return func(c *providerConfig) {
|
||||||
|
c.requestTimeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProvider creates a new AWS Bedrock provider.
|
||||||
|
// It uses the default AWS credential chain (env vars, shared config, IAM roles, etc.).
|
||||||
|
func NewProvider(ctx context.Context, opts ...Option) (*Provider, error) {
|
||||||
|
pc := &providerConfig{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(pc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build AWS config options
|
||||||
|
var configOpts []func(*config.LoadOptions) error
|
||||||
|
|
||||||
|
if pc.region != "" {
|
||||||
|
configOpts = append(configOpts, config.WithRegion(pc.region))
|
||||||
|
}
|
||||||
|
|
||||||
|
if pc.profile != "" {
|
||||||
|
configOpts = append(configOpts, config.WithSharedConfigProfile(pc.profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load AWS config with automatic credential discovery
|
||||||
|
cfg, err := config.LoadDefaultConfig(ctx, configOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading AWS config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate region is set - required for Bedrock request signing
|
||||||
|
if cfg.Region == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"AWS region not configured: set AWS_REGION, AWS_DEFAULT_REGION, or use WithRegion option",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build client options
|
||||||
|
var clientOpts []func(*bedrockruntime.Options)
|
||||||
|
if pc.baseEndpoint != "" {
|
||||||
|
clientOpts = append(clientOpts, func(o *bedrockruntime.Options) {
|
||||||
|
o.BaseEndpoint = aws.String(pc.baseEndpoint)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
client := bedrockruntime.NewFromConfig(cfg, clientOpts...)
|
||||||
|
|
||||||
|
return &Provider{
|
||||||
|
client: client,
|
||||||
|
region: cfg.Region,
|
||||||
|
requestTimeout: pc.requestTimeout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat sends messages to AWS Bedrock using the Converse API.
|
||||||
|
func (p *Provider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []Message,
|
||||||
|
tools []ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*LLMResponse, error) {
|
||||||
|
// Apply request timeout if context doesn't already have a deadline.
|
||||||
|
// Use explicit timeout if set, otherwise fall back to common default.
|
||||||
|
effectiveTimeout := p.requestTimeout
|
||||||
|
if effectiveTimeout <= 0 {
|
||||||
|
effectiveTimeout = common.DefaultRequestTimeout
|
||||||
|
}
|
||||||
|
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, effectiveTimeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the Converse API input
|
||||||
|
input := &bedrockruntime.ConverseInput{
|
||||||
|
ModelId: aws.String(model),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert messages to Bedrock format
|
||||||
|
bedrockMessages, systemPrompts := convertMessages(messages)
|
||||||
|
input.Messages = bedrockMessages
|
||||||
|
|
||||||
|
// Set system prompts if any
|
||||||
|
if len(systemPrompts) > 0 {
|
||||||
|
input.System = systemPrompts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set inference configuration only when options are provided
|
||||||
|
var inferenceConfig *types.InferenceConfiguration
|
||||||
|
|
||||||
|
if maxTokens, ok := common.AsInt(options["max_tokens"]); ok && maxTokens > 0 {
|
||||||
|
if inferenceConfig == nil {
|
||||||
|
inferenceConfig = &types.InferenceConfiguration{}
|
||||||
|
}
|
||||||
|
// Clamp to int32 range to avoid overflow
|
||||||
|
if maxTokens > math.MaxInt32 {
|
||||||
|
maxTokens = math.MaxInt32
|
||||||
|
}
|
||||||
|
inferenceConfig.MaxTokens = aws.Int32(int32(maxTokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
if temp, ok := common.AsFloat(options["temperature"]); ok {
|
||||||
|
if inferenceConfig == nil {
|
||||||
|
inferenceConfig = &types.InferenceConfiguration{}
|
||||||
|
}
|
||||||
|
inferenceConfig.Temperature = aws.Float32(float32(temp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if inferenceConfig != nil {
|
||||||
|
input.InferenceConfig = inferenceConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tools to Bedrock format
|
||||||
|
// Only set ToolConfig if at least one valid tool was produced
|
||||||
|
if len(tools) > 0 {
|
||||||
|
toolConfig := convertTools(tools)
|
||||||
|
if len(toolConfig.Tools) > 0 {
|
||||||
|
input.ToolConfig = toolConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Bedrock Converse API
|
||||||
|
output, err := p.client.Converse(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bedrock converse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response
|
||||||
|
return parseResponse(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultModel returns an empty string as Bedrock models are user-configured.
|
||||||
|
func (p *Provider) GetDefaultModel() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region returns the AWS region configured for this Provider.
|
||||||
|
func (p *Provider) Region() string {
|
||||||
|
return p.region
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMessages converts internal messages to Bedrock Converse format.
|
||||||
|
// Returns the conversation messages and any system prompts separately.
|
||||||
|
// Note: Bedrock requires all tool results for a given assistant turn to be in a single
|
||||||
|
// user message with multiple ToolResultBlock content blocks. This function merges
|
||||||
|
// consecutive tool result messages accordingly.
|
||||||
|
func convertMessages(messages []Message) ([]types.Message, []types.SystemContentBlock) {
|
||||||
|
var bedrockMessages []types.Message
|
||||||
|
var systemPrompts []types.SystemContentBlock
|
||||||
|
|
||||||
|
// Helper to check if a message is a tool result
|
||||||
|
isToolResult := func(msg Message) bool {
|
||||||
|
return (msg.Role == "tool" || (msg.Role == "user" && msg.ToolCallID != "")) && msg.ToolCallID != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a tool result content block
|
||||||
|
makeToolResultBlock := func(msg Message) types.ContentBlock {
|
||||||
|
return &types.ContentBlockMemberToolResult{
|
||||||
|
Value: types.ToolResultBlock{
|
||||||
|
ToolUseId: aws.String(msg.ToolCallID),
|
||||||
|
Content: []types.ToolResultContentBlock{
|
||||||
|
&types.ToolResultContentBlockMemberText{
|
||||||
|
Value: msg.Content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(messages) {
|
||||||
|
msg := messages[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case msg.Role == "system":
|
||||||
|
// System messages go to the System field
|
||||||
|
systemPrompts = append(systemPrompts, &types.SystemContentBlockMemberText{
|
||||||
|
Value: msg.Content,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
|
||||||
|
case isToolResult(msg):
|
||||||
|
// Collect all consecutive tool results into a single user message
|
||||||
|
// Bedrock requires all tool results for a turn in one message
|
||||||
|
var toolResultBlocks []types.ContentBlock
|
||||||
|
for i < len(messages) && isToolResult(messages[i]) {
|
||||||
|
toolResultBlocks = append(toolResultBlocks, makeToolResultBlock(messages[i]))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
bedrockMessages = append(bedrockMessages, types.Message{
|
||||||
|
Role: types.ConversationRoleUser,
|
||||||
|
Content: toolResultBlocks,
|
||||||
|
})
|
||||||
|
|
||||||
|
case msg.Role == "user":
|
||||||
|
// Regular user message (no ToolCallID)
|
||||||
|
content := buildUserContent(msg)
|
||||||
|
bedrockMessages = append(bedrockMessages, types.Message{
|
||||||
|
Role: types.ConversationRoleUser,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
|
||||||
|
case msg.Role == "assistant":
|
||||||
|
content := buildAssistantContent(msg)
|
||||||
|
bedrockMessages = append(bedrockMessages, types.Message{
|
||||||
|
Role: types.ConversationRoleAssistant,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
|
||||||
|
case msg.Role == "tool" && msg.ToolCallID == "":
|
||||||
|
// Tool message without ToolCallID - treat as regular user message
|
||||||
|
content := buildUserContent(msg)
|
||||||
|
bedrockMessages = append(bedrockMessages, types.Message{
|
||||||
|
Role: types.ConversationRoleUser,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown role - skip
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bedrockMessages, systemPrompts
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUserContent builds Bedrock content blocks for a user message.
|
||||||
|
func buildUserContent(msg Message) []types.ContentBlock {
|
||||||
|
var content []types.ContentBlock
|
||||||
|
|
||||||
|
// Add text content
|
||||||
|
if msg.Content != "" {
|
||||||
|
content = append(content, &types.ContentBlockMemberText{
|
||||||
|
Value: msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add images from Media field
|
||||||
|
for _, mediaURL := range msg.Media {
|
||||||
|
if strings.HasPrefix(mediaURL, "data:image/") {
|
||||||
|
// Parse data URL: data:image/jpeg;base64,<data>
|
||||||
|
parts := strings.SplitN(mediaURL, ",", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract media type from "data:image/jpeg;base64"
|
||||||
|
mediaType := ""
|
||||||
|
header := parts[0]
|
||||||
|
if idx := strings.Index(header, "/"); idx != -1 {
|
||||||
|
end := strings.Index(header[idx:], ";")
|
||||||
|
if end == -1 {
|
||||||
|
end = len(header) - idx
|
||||||
|
}
|
||||||
|
mediaType = header[idx+1 : idx+end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify this is base64 encoded
|
||||||
|
if !strings.Contains(header, ";base64") {
|
||||||
|
continue // Skip non-base64 encoded data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map media type to Bedrock format
|
||||||
|
var format types.ImageFormat
|
||||||
|
switch mediaType {
|
||||||
|
case "jpeg", "jpg":
|
||||||
|
format = types.ImageFormatJpeg
|
||||||
|
case "png":
|
||||||
|
format = types.ImageFormatPng
|
||||||
|
case "gif":
|
||||||
|
format = types.ImageFormatGif
|
||||||
|
case "webp":
|
||||||
|
format = types.ImageFormatWebp
|
||||||
|
default:
|
||||||
|
continue // Skip unsupported formats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check size before decoding to prevent excessive memory allocation
|
||||||
|
// Bedrock has a ~20MB request limit; cap decoded images at 10MB
|
||||||
|
const maxImageSize = 10 * 1024 * 1024
|
||||||
|
decodedLen := base64.StdEncoding.DecodedLen(len(parts[1]))
|
||||||
|
if decodedLen > maxImageSize {
|
||||||
|
log.Printf("bedrock: skipping image exceeding size limit (%d bytes > %d)", decodedLen, maxImageSize)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
imageData, err := base64.StdEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("bedrock: failed to decode base64 image data: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content = append(content, &types.ContentBlockMemberImage{
|
||||||
|
Value: types.ImageBlock{
|
||||||
|
Format: format,
|
||||||
|
Source: &types.ImageSourceMemberBytes{
|
||||||
|
Value: imageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock requires at least one content block; add empty text if needed
|
||||||
|
if len(content) == 0 {
|
||||||
|
content = append(content, &types.ContentBlockMemberText{Value: ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAssistantContent builds Bedrock content blocks for an assistant message.
|
||||||
|
func buildAssistantContent(msg Message) []types.ContentBlock {
|
||||||
|
var content []types.ContentBlock
|
||||||
|
|
||||||
|
// Add text content if present
|
||||||
|
if msg.Content != "" {
|
||||||
|
content = append(content, &types.ContentBlockMemberText{
|
||||||
|
Value: msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool use blocks
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
// Validate tool call ID - Bedrock requires non-empty ToolUseId
|
||||||
|
if strings.TrimSpace(tc.ID) == "" {
|
||||||
|
log.Printf("bedrock: skipping tool call with empty ID (name: %q)", tc.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve tool name: prefer tc.Name, fallback to tc.Function.Name
|
||||||
|
// (tc.Name/tc.Arguments are json:"-" and may be empty when from JSON)
|
||||||
|
toolName := tc.Name
|
||||||
|
if toolName == "" && tc.Function != nil {
|
||||||
|
toolName = tc.Function.Name
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(toolName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve arguments: prefer tc.Arguments, fallback to parsing tc.Function.Arguments
|
||||||
|
args := tc.Arguments
|
||||||
|
if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||||
|
log.Printf("bedrock: failed to parse Function.Arguments for tool %q: %v", toolName, err)
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert arguments to a Bedrock document using NewLazyDocument
|
||||||
|
inputDoc := document.NewLazyDocument(args)
|
||||||
|
|
||||||
|
content = append(content, &types.ContentBlockMemberToolUse{
|
||||||
|
Value: types.ToolUseBlock{
|
||||||
|
ToolUseId: aws.String(tc.ID),
|
||||||
|
Name: aws.String(toolName),
|
||||||
|
Input: inputDoc,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock requires at least one content block; add empty text if needed
|
||||||
|
if len(content) == 0 {
|
||||||
|
content = append(content, &types.ContentBlockMemberText{Value: ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertTools converts tool definitions to Bedrock format.
|
||||||
|
func convertTools(tools []ToolDefinition) *types.ToolConfiguration {
|
||||||
|
bedrockTools := make([]types.Tool, 0, len(tools))
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
// Skip tools with empty names
|
||||||
|
if strings.TrimSpace(tool.Function.Name) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parameters is not nil - default to minimal object schema
|
||||||
|
params := tool.Function.Parameters
|
||||||
|
if params == nil {
|
||||||
|
params = map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert parameters schema to a Bedrock document
|
||||||
|
inputSchema := document.NewLazyDocument(params)
|
||||||
|
|
||||||
|
bedrockTools = append(bedrockTools, &types.ToolMemberToolSpec{
|
||||||
|
Value: types.ToolSpecification{
|
||||||
|
Name: aws.String(tool.Function.Name),
|
||||||
|
Description: aws.String(tool.Function.Description),
|
||||||
|
InputSchema: &types.ToolInputSchemaMemberJson{
|
||||||
|
Value: inputSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.ToolConfiguration{
|
||||||
|
Tools: bedrockTools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResponse converts Bedrock Converse output to LLMResponse.
|
||||||
|
func parseResponse(output *bedrockruntime.ConverseOutput) (*LLMResponse, error) {
|
||||||
|
var content strings.Builder
|
||||||
|
toolCalls := make([]ToolCall, 0)
|
||||||
|
|
||||||
|
// Process output content blocks
|
||||||
|
if output.Output != nil {
|
||||||
|
if msgOutput, ok := output.Output.(*types.ConverseOutputMemberMessage); ok {
|
||||||
|
for _, block := range msgOutput.Value.Content {
|
||||||
|
switch b := block.(type) {
|
||||||
|
case *types.ContentBlockMemberText:
|
||||||
|
content.WriteString(b.Value)
|
||||||
|
|
||||||
|
case *types.ContentBlockMemberToolUse:
|
||||||
|
// Unmarshal the document interface to a map
|
||||||
|
args := make(map[string]any)
|
||||||
|
if b.Value.Input != nil {
|
||||||
|
if err := b.Value.Input.UnmarshalSmithyDocument(&args); err != nil {
|
||||||
|
log.Printf("bedrock: failed to unmarshal tool input for tool %q (id %q): %v",
|
||||||
|
aws.ToString(b.Value.Name),
|
||||||
|
aws.ToString(b.Value.ToolUseId),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
args = make(map[string]any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize arguments to JSON string for FunctionCall
|
||||||
|
argsJSON, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("bedrock: failed to marshal tool arguments for tool %q (id %q): %v",
|
||||||
|
aws.ToString(b.Value.Name),
|
||||||
|
aws.ToString(b.Value.ToolUseId),
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
argsJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
|
ID: aws.ToString(b.Value.ToolUseId),
|
||||||
|
Name: aws.ToString(b.Value.Name),
|
||||||
|
Arguments: args,
|
||||||
|
Function: &FunctionCall{
|
||||||
|
Name: aws.ToString(b.Value.Name),
|
||||||
|
Arguments: string(argsJSON),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map stop reason
|
||||||
|
finishReason := "stop"
|
||||||
|
switch output.StopReason {
|
||||||
|
case types.StopReasonToolUse:
|
||||||
|
finishReason = "tool_calls"
|
||||||
|
case types.StopReasonMaxTokens:
|
||||||
|
finishReason = "length"
|
||||||
|
case types.StopReasonEndTurn:
|
||||||
|
finishReason = "stop"
|
||||||
|
case types.StopReasonStopSequence:
|
||||||
|
finishReason = "stop"
|
||||||
|
case types.StopReasonContentFiltered:
|
||||||
|
finishReason = "content_filter"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build usage info
|
||||||
|
var usage *UsageInfo
|
||||||
|
if output.Usage != nil {
|
||||||
|
usage = &UsageInfo{
|
||||||
|
PromptTokens: int(aws.ToInt32(output.Usage.InputTokens)),
|
||||||
|
CompletionTokens: int(aws.ToInt32(output.Usage.OutputTokens)),
|
||||||
|
TotalTokens: int(aws.ToInt32(output.Usage.InputTokens)) + int(aws.ToInt32(output.Usage.OutputTokens)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: content.String(),
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
FinishReason: finishReason,
|
||||||
|
Usage: usage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
541
pkg/providers/bedrock/provider_bedrock_test.go
Normal file
541
pkg/providers/bedrock/provider_bedrock_test.go
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
//go:build bedrock
|
||||||
|
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package bedrock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertMessages_SystemPrompts(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "system", Content: "You are a helpful assistant."},
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
bedrockMsgs, systemPrompts := convertMessages(messages)
|
||||||
|
|
||||||
|
assert.Len(t, systemPrompts, 1)
|
||||||
|
assert.Len(t, bedrockMsgs, 1)
|
||||||
|
|
||||||
|
// Check system prompt
|
||||||
|
textBlock, ok := systemPrompts[0].(*types.SystemContentBlockMemberText)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "You are a helpful assistant.", textBlock.Value)
|
||||||
|
|
||||||
|
// Check user message
|
||||||
|
assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertMessages_UserMessage(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "What is 2+2?"},
|
||||||
|
}
|
||||||
|
|
||||||
|
bedrockMsgs, systemPrompts := convertMessages(messages)
|
||||||
|
|
||||||
|
assert.Empty(t, systemPrompts)
|
||||||
|
assert.Len(t, bedrockMsgs, 1)
|
||||||
|
assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role)
|
||||||
|
|
||||||
|
textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "What is 2+2?", textBlock.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertMessages_AssistantMessage(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "assistant", Content: "The answer is 4."},
|
||||||
|
}
|
||||||
|
|
||||||
|
bedrockMsgs, _ := convertMessages(messages)
|
||||||
|
|
||||||
|
assert.Len(t, bedrockMsgs, 1)
|
||||||
|
assert.Equal(t, types.ConversationRoleAssistant, bedrockMsgs[0].Role)
|
||||||
|
|
||||||
|
textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "The answer is 4.", textBlock.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertMessages_ToolResult(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "tool", Content: "Result from tool", ToolCallID: "call_123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
bedrockMsgs, _ := convertMessages(messages)
|
||||||
|
|
||||||
|
assert.Len(t, bedrockMsgs, 1)
|
||||||
|
assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role)
|
||||||
|
|
||||||
|
toolResult, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberToolResult)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "call_123", aws.ToString(toolResult.Value.ToolUseId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertMessages_MultipleToolResultsMerged(t *testing.T) {
|
||||||
|
// When an assistant makes multiple tool calls, all tool results must be
|
||||||
|
// merged into a single user message for Bedrock
|
||||||
|
messages := []Message{
|
||||||
|
{Role: "user", Content: "What's the weather in NYC and LA?"},
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Let me check both cities.",
|
||||||
|
ToolCalls: []protocoltypes.ToolCall{
|
||||||
|
{ID: "call_nyc", Name: "get_weather", Arguments: map[string]any{"city": "NYC"}},
|
||||||
|
{ID: "call_la", Name: "get_weather", Arguments: map[string]any{"city": "LA"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: "NYC: 72°F, sunny", ToolCallID: "call_nyc"},
|
||||||
|
{Role: "tool", Content: "LA: 85°F, clear", ToolCallID: "call_la"},
|
||||||
|
}
|
||||||
|
|
||||||
|
bedrockMsgs, _ := convertMessages(messages)
|
||||||
|
|
||||||
|
// Should be: user message, assistant message, merged tool results (single user message)
|
||||||
|
assert.Len(t, bedrockMsgs, 3)
|
||||||
|
|
||||||
|
// First message: user
|
||||||
|
assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[0].Role)
|
||||||
|
|
||||||
|
// Second message: assistant with tool calls
|
||||||
|
assert.Equal(t, types.ConversationRoleAssistant, bedrockMsgs[1].Role)
|
||||||
|
|
||||||
|
// Third message: merged tool results in single user message
|
||||||
|
assert.Equal(t, types.ConversationRoleUser, bedrockMsgs[2].Role)
|
||||||
|
assert.Len(t, bedrockMsgs[2].Content, 2) // Both tool results in one message
|
||||||
|
|
||||||
|
// Verify both tool results are present
|
||||||
|
result1, ok := bedrockMsgs[2].Content[0].(*types.ContentBlockMemberToolResult)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "call_nyc", aws.ToString(result1.Value.ToolUseId))
|
||||||
|
|
||||||
|
result2, ok := bedrockMsgs[2].Content[1].(*types.ContentBlockMemberToolResult)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "call_la", aws.ToString(result2.Value.ToolUseId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertMessages_AssistantWithToolCalls(t *testing.T) {
|
||||||
|
messages := []Message{
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Let me calculate that.",
|
||||||
|
ToolCalls: []protocoltypes.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_456",
|
||||||
|
Name: "calculator",
|
||||||
|
Arguments: map[string]any{"expression": "2+2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bedrockMsgs, _ := convertMessages(messages)
|
||||||
|
|
||||||
|
assert.Len(t, bedrockMsgs, 1)
|
||||||
|
assert.Len(t, bedrockMsgs[0].Content, 2) // text + tool use
|
||||||
|
|
||||||
|
// Check text content
|
||||||
|
textBlock, ok := bedrockMsgs[0].Content[0].(*types.ContentBlockMemberText)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Let me calculate that.", textBlock.Value)
|
||||||
|
|
||||||
|
// Check tool use
|
||||||
|
toolUse, ok := bedrockMsgs[0].Content[1].(*types.ContentBlockMemberToolUse)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "call_456", aws.ToString(toolUse.Value.ToolUseId))
|
||||||
|
assert.Equal(t, "calculator", aws.ToString(toolUse.Value.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertTools_Basic(t *testing.T) {
|
||||||
|
tools := []ToolDefinition{
|
||||||
|
{
|
||||||
|
Function: protocoltypes.ToolFunctionDefinition{
|
||||||
|
Name: "get_weather",
|
||||||
|
Description: "Get the current weather",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"location": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
toolConfig := convertTools(tools)
|
||||||
|
|
||||||
|
assert.NotNil(t, toolConfig)
|
||||||
|
assert.Len(t, toolConfig.Tools, 1)
|
||||||
|
|
||||||
|
toolSpec, ok := toolConfig.Tools[0].(*types.ToolMemberToolSpec)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "get_weather", aws.ToString(toolSpec.Value.Name))
|
||||||
|
assert.Equal(t, "Get the current weather", aws.ToString(toolSpec.Value.Description))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertTools_SkipsEmptyName(t *testing.T) {
|
||||||
|
tools := []ToolDefinition{
|
||||||
|
{
|
||||||
|
Function: protocoltypes.ToolFunctionDefinition{
|
||||||
|
Name: "",
|
||||||
|
Description: "Empty name tool",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Function: protocoltypes.ToolFunctionDefinition{
|
||||||
|
Name: " ",
|
||||||
|
Description: "Whitespace name tool",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Function: protocoltypes.ToolFunctionDefinition{
|
||||||
|
Name: "valid_tool",
|
||||||
|
Description: "Valid tool",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
toolConfig := convertTools(tools)
|
||||||
|
|
||||||
|
assert.Len(t, toolConfig.Tools, 1)
|
||||||
|
toolSpec := toolConfig.Tools[0].(*types.ToolMemberToolSpec)
|
||||||
|
assert.Equal(t, "valid_tool", aws.ToString(toolSpec.Value.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertTools_NilParameters(t *testing.T) {
|
||||||
|
tools := []ToolDefinition{
|
||||||
|
{
|
||||||
|
Function: protocoltypes.ToolFunctionDefinition{
|
||||||
|
Name: "simple_tool",
|
||||||
|
Description: "A tool with no parameters",
|
||||||
|
Parameters: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
toolConfig := convertTools(tools)
|
||||||
|
|
||||||
|
assert.Len(t, toolConfig.Tools, 1)
|
||||||
|
// Should not panic and should create a valid tool
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUserContent_TextOnly(t *testing.T) {
|
||||||
|
msg := Message{Content: "Hello world"}
|
||||||
|
|
||||||
|
content := buildUserContent(msg)
|
||||||
|
|
||||||
|
assert.Len(t, content, 1)
|
||||||
|
textBlock, ok := content[0].(*types.ContentBlockMemberText)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Hello world", textBlock.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUserContent_WithImage(t *testing.T) {
|
||||||
|
// Base64-encoded 1x1 PNG (the provider doesn't validate image correctness,
|
||||||
|
// it just verifies the format and base64 decoding works)
|
||||||
|
b64Data := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="
|
||||||
|
|
||||||
|
msg := Message{
|
||||||
|
Content: "Look at this image",
|
||||||
|
Media: []string{"data:image/png;base64," + b64Data},
|
||||||
|
}
|
||||||
|
|
||||||
|
content := buildUserContent(msg)
|
||||||
|
|
||||||
|
assert.Len(t, content, 2)
|
||||||
|
|
||||||
|
// Check text
|
||||||
|
textBlock, ok := content[0].(*types.ContentBlockMemberText)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "Look at this image", textBlock.Value)
|
||||||
|
|
||||||
|
// Check image
|
||||||
|
imageBlock, ok := content[1].(*types.ContentBlockMemberImage)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, types.ImageFormatPng, imageBlock.Value.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUserContent_SkipsInvalidBase64(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
Content: "Invalid image",
|
||||||
|
Media: []string{"data:image/png;base64,not-valid-base64!!!"},
|
||||||
|
}
|
||||||
|
|
||||||
|
content := buildUserContent(msg)
|
||||||
|
|
||||||
|
// Should only have text, image should be skipped
|
||||||
|
assert.Len(t, content, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUserContent_SkipsNonBase64Data(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
Content: "Non-base64 image",
|
||||||
|
Media: []string{"data:image/png,raw-data-here"},
|
||||||
|
}
|
||||||
|
|
||||||
|
content := buildUserContent(msg)
|
||||||
|
|
||||||
|
// Should only have text, non-base64 image should be skipped
|
||||||
|
assert.Len(t, content, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAssistantContent_SkipsEmptyToolName(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
Content: "Response",
|
||||||
|
ToolCalls: []protocoltypes.ToolCall{
|
||||||
|
{ID: "1", Name: "", Arguments: map[string]any{}},
|
||||||
|
{ID: "2", Name: " ", Arguments: map[string]any{}},
|
||||||
|
{ID: "3", Name: "valid", Arguments: map[string]any{}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content := buildAssistantContent(msg)
|
||||||
|
|
||||||
|
// Should have text + 1 valid tool
|
||||||
|
assert.Len(t, content, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAssistantContent_NilArguments(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
ToolCalls: []protocoltypes.ToolCall{
|
||||||
|
{ID: "1", Name: "tool", Arguments: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content := buildAssistantContent(msg)
|
||||||
|
|
||||||
|
assert.Len(t, content, 1)
|
||||||
|
toolUse, ok := content[0].(*types.ContentBlockMemberToolUse)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.NotNil(t, toolUse.Value.Input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAssistantContent_FunctionFallback(t *testing.T) {
|
||||||
|
// When Name/Arguments are empty (json:"-"), should fallback to Function fields
|
||||||
|
msg := Message{
|
||||||
|
ToolCalls: []protocoltypes.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "1",
|
||||||
|
Name: "", // empty, should fallback to Function.Name
|
||||||
|
Function: &protocoltypes.FunctionCall{
|
||||||
|
Name: "fallback_tool",
|
||||||
|
Arguments: `{"key":"value"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content := buildAssistantContent(msg)
|
||||||
|
|
||||||
|
assert.Len(t, content, 1)
|
||||||
|
toolUse, ok := content[0].(*types.ContentBlockMemberToolUse)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "fallback_tool", aws.ToString(toolUse.Value.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_TextOnly(t *testing.T) {
|
||||||
|
output := &bedrockruntime.ConverseOutput{
|
||||||
|
Output: &types.ConverseOutputMemberMessage{
|
||||||
|
Value: types.Message{
|
||||||
|
Role: types.ConversationRoleAssistant,
|
||||||
|
Content: []types.ContentBlock{
|
||||||
|
&types.ContentBlockMemberText{Value: "Hello!"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StopReason: types.StopReasonEndTurn,
|
||||||
|
Usage: &types.TokenUsage{
|
||||||
|
InputTokens: aws.Int32(10),
|
||||||
|
OutputTokens: aws.Int32(5),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := parseResponse(output)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Hello!", resp.Content)
|
||||||
|
assert.Equal(t, "stop", resp.FinishReason)
|
||||||
|
assert.Empty(t, resp.ToolCalls)
|
||||||
|
assert.Equal(t, 10, resp.Usage.PromptTokens)
|
||||||
|
assert.Equal(t, 5, resp.Usage.CompletionTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_StopReasons(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
stopReason types.StopReason
|
||||||
|
expectedFinish string
|
||||||
|
}{
|
||||||
|
{types.StopReasonEndTurn, "stop"},
|
||||||
|
{types.StopReasonToolUse, "tool_calls"},
|
||||||
|
{types.StopReasonMaxTokens, "length"},
|
||||||
|
{types.StopReasonStopSequence, "stop"},
|
||||||
|
{types.StopReasonContentFiltered, "content_filter"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.stopReason), func(t *testing.T) {
|
||||||
|
output := &bedrockruntime.ConverseOutput{
|
||||||
|
Output: &types.ConverseOutputMemberMessage{
|
||||||
|
Value: types.Message{
|
||||||
|
Content: []types.ContentBlock{
|
||||||
|
&types.ContentBlockMemberText{Value: "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StopReason: tt.stopReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := parseResponse(output)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expectedFinish, resp.FinishReason)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_WithToolCalls(t *testing.T) {
|
||||||
|
// Note: document.NewLazyDocument has limitations with UnmarshalSmithyDocument in tests,
|
||||||
|
// so we test the structure extraction and verify Arguments gets populated (even if empty
|
||||||
|
// due to SDK limitations). The actual unmarshal works correctly at runtime.
|
||||||
|
toolInput := document.NewLazyDocument(map[string]any{
|
||||||
|
"location": "San Francisco",
|
||||||
|
"unit": "celsius",
|
||||||
|
})
|
||||||
|
|
||||||
|
output := &bedrockruntime.ConverseOutput{
|
||||||
|
Output: &types.ConverseOutputMemberMessage{
|
||||||
|
Value: types.Message{
|
||||||
|
Role: types.ConversationRoleAssistant,
|
||||||
|
Content: []types.ContentBlock{
|
||||||
|
&types.ContentBlockMemberText{Value: "Let me check the weather."},
|
||||||
|
&types.ContentBlockMemberToolUse{
|
||||||
|
Value: types.ToolUseBlock{
|
||||||
|
ToolUseId: aws.String("call_weather_123"),
|
||||||
|
Name: aws.String("get_weather"),
|
||||||
|
Input: toolInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StopReason: types.StopReasonToolUse,
|
||||||
|
Usage: &types.TokenUsage{
|
||||||
|
InputTokens: aws.Int32(20),
|
||||||
|
OutputTokens: aws.Int32(15),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := parseResponse(output)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Let me check the weather.", resp.Content)
|
||||||
|
assert.Equal(t, "tool_calls", resp.FinishReason)
|
||||||
|
assert.Len(t, resp.ToolCalls, 1)
|
||||||
|
|
||||||
|
// Verify tool call ID and Name are extracted correctly
|
||||||
|
tc := resp.ToolCalls[0]
|
||||||
|
assert.Equal(t, "call_weather_123", tc.ID)
|
||||||
|
assert.Equal(t, "get_weather", tc.Name)
|
||||||
|
|
||||||
|
// Verify Function fields are also populated
|
||||||
|
require.NotNil(t, tc.Function)
|
||||||
|
assert.Equal(t, "get_weather", tc.Function.Name)
|
||||||
|
|
||||||
|
// Verify Arguments is not nil (content may vary due to SDK limitations in tests)
|
||||||
|
assert.NotNil(t, tc.Arguments)
|
||||||
|
|
||||||
|
// Verify usage
|
||||||
|
assert.Equal(t, 20, resp.Usage.PromptTokens)
|
||||||
|
assert.Equal(t, 15, resp.Usage.CompletionTokens)
|
||||||
|
assert.Equal(t, 35, resp.Usage.TotalTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_MultipleToolCalls(t *testing.T) {
|
||||||
|
output := &bedrockruntime.ConverseOutput{
|
||||||
|
Output: &types.ConverseOutputMemberMessage{
|
||||||
|
Value: types.Message{
|
||||||
|
Role: types.ConversationRoleAssistant,
|
||||||
|
Content: []types.ContentBlock{
|
||||||
|
&types.ContentBlockMemberToolUse{
|
||||||
|
Value: types.ToolUseBlock{
|
||||||
|
ToolUseId: aws.String("call_1"),
|
||||||
|
Name: aws.String("tool_a"),
|
||||||
|
Input: document.NewLazyDocument(map[string]any{"arg": "value1"}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&types.ContentBlockMemberToolUse{
|
||||||
|
Value: types.ToolUseBlock{
|
||||||
|
ToolUseId: aws.String("call_2"),
|
||||||
|
Name: aws.String("tool_b"),
|
||||||
|
Input: document.NewLazyDocument(map[string]any{"arg": "value2"}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StopReason: types.StopReasonToolUse,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := parseResponse(output)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "tool_calls", resp.FinishReason)
|
||||||
|
assert.Len(t, resp.ToolCalls, 2)
|
||||||
|
|
||||||
|
// Verify tool call structure
|
||||||
|
assert.Equal(t, "call_1", resp.ToolCalls[0].ID)
|
||||||
|
assert.Equal(t, "tool_a", resp.ToolCalls[0].Name)
|
||||||
|
assert.NotNil(t, resp.ToolCalls[0].Arguments)
|
||||||
|
assert.NotNil(t, resp.ToolCalls[0].Function)
|
||||||
|
assert.Equal(t, "tool_a", resp.ToolCalls[0].Function.Name)
|
||||||
|
|
||||||
|
assert.Equal(t, "call_2", resp.ToolCalls[1].ID)
|
||||||
|
assert.Equal(t, "tool_b", resp.ToolCalls[1].Name)
|
||||||
|
assert.NotNil(t, resp.ToolCalls[1].Arguments)
|
||||||
|
assert.NotNil(t, resp.ToolCalls[1].Function)
|
||||||
|
assert.Equal(t, "tool_b", resp.ToolCalls[1].Function.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResponse_ToolCallWithNilInput(t *testing.T) {
|
||||||
|
output := &bedrockruntime.ConverseOutput{
|
||||||
|
Output: &types.ConverseOutputMemberMessage{
|
||||||
|
Value: types.Message{
|
||||||
|
Role: types.ConversationRoleAssistant,
|
||||||
|
Content: []types.ContentBlock{
|
||||||
|
&types.ContentBlockMemberToolUse{
|
||||||
|
Value: types.ToolUseBlock{
|
||||||
|
ToolUseId: aws.String("call_nil"),
|
||||||
|
Name: aws.String("no_args_tool"),
|
||||||
|
Input: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StopReason: types.StopReasonToolUse,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := parseResponse(output)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, resp.ToolCalls, 1)
|
||||||
|
assert.Equal(t, "call_nil", resp.ToolCalls[0].ID)
|
||||||
|
assert.Equal(t, "no_args_tool", resp.ToolCalls[0].Name)
|
||||||
|
// Arguments should be empty map, not nil
|
||||||
|
assert.NotNil(t, resp.ToolCalls[0].Arguments)
|
||||||
|
assert.Empty(t, resp.ToolCalls[0].Arguments)
|
||||||
|
}
|
||||||
73
pkg/providers/bedrock/provider_stub.go
Normal file
73
pkg/providers/bedrock/provider_stub.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
//go:build !bedrock
|
||||||
|
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
// Package bedrock provides a stub implementation when built without the bedrock tag.
|
||||||
|
// To enable AWS Bedrock support, build with: go build -tags bedrock
|
||||||
|
package bedrock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
LLMResponse = protocoltypes.LLMResponse
|
||||||
|
Message = protocoltypes.Message
|
||||||
|
ToolDefinition = protocoltypes.ToolDefinition
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider is a stub that returns an error when Bedrock support is not compiled in.
|
||||||
|
type Provider struct{}
|
||||||
|
|
||||||
|
// Option is a no-op when Bedrock is not enabled.
|
||||||
|
type Option func(*providerConfig)
|
||||||
|
|
||||||
|
type providerConfig struct{}
|
||||||
|
|
||||||
|
// WithRegion is a no-op when Bedrock is not enabled.
|
||||||
|
func WithRegion(region string) Option {
|
||||||
|
return func(c *providerConfig) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfile is a no-op when Bedrock is not enabled.
|
||||||
|
func WithProfile(profile string) Option {
|
||||||
|
return func(c *providerConfig) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBaseEndpoint is a no-op when Bedrock is not enabled.
|
||||||
|
func WithBaseEndpoint(endpoint string) Option {
|
||||||
|
return func(c *providerConfig) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRequestTimeout is a no-op when Bedrock is not enabled.
|
||||||
|
func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
|
return func(c *providerConfig) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProvider returns an error indicating Bedrock support is not compiled in.
|
||||||
|
func NewProvider(ctx context.Context, opts ...Option) (*Provider, error) {
|
||||||
|
return nil, fmt.Errorf("bedrock provider not available: build with -tags bedrock to enable AWS Bedrock support")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat returns an error - this should never be called since NewProvider fails.
|
||||||
|
func (p *Provider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []Message,
|
||||||
|
tools []ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*LLMResponse, error) {
|
||||||
|
return nil, fmt.Errorf("bedrock provider not available: build with -tags bedrock to enable AWS Bedrock support")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultModel returns an empty string.
|
||||||
|
func (p *Provider) GetDefaultModel() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
35
pkg/providers/bedrock/provider_stub_test.go
Normal file
35
pkg/providers/bedrock/provider_stub_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
//go:build !bedrock
|
||||||
|
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// License: MIT
|
||||||
|
//
|
||||||
|
// Copyright (c) 2026 PicoClaw contributors
|
||||||
|
|
||||||
|
package bedrock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewProvider_ReturnsStubError(t *testing.T) {
|
||||||
|
provider, err := NewProvider(context.Background())
|
||||||
|
|
||||||
|
assert.Nil(t, provider)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, strings.Contains(err.Error(), "build with -tags bedrock"),
|
||||||
|
"error should mention build tag requirement, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewProvider_WithOptions_ReturnsStubError(t *testing.T) {
|
||||||
|
provider, err := NewProvider(context.Background(), WithRegion("us-west-2"), WithProfile("test"))
|
||||||
|
|
||||||
|
assert.Nil(t, provider)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, strings.Contains(err.Error(), "build with -tags bedrock"),
|
||||||
|
"error should mention build tag requirement, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,15 @@
|
||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
|
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/azure"
|
"github.com/sipeed/picoclaw/pkg/providers/azure"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
|
||||||
)
|
)
|
||||||
|
|
||||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||||
|
|
@ -55,8 +58,9 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
||||||
|
|
||||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||||
// Supported protocols: openai, litellm, novita, anthropic, anthropic-messages,
|
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),
|
||||||
// antigravity, claude-cli, codex-cli, github-copilot
|
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
|
||||||
|
// See the switch on protocol in this function for the authoritative list.
|
||||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
|
|
@ -114,6 +118,42 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
case "bedrock":
|
||||||
|
// AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.)
|
||||||
|
// api_base can be:
|
||||||
|
// - A full endpoint URL: https://bedrock-runtime.us-east-1.amazonaws.com
|
||||||
|
// - A region name: us-east-1 (AWS SDK resolves endpoint automatically)
|
||||||
|
var opts []bedrock.Option
|
||||||
|
if cfg.APIBase != "" {
|
||||||
|
if !strings.Contains(cfg.APIBase, "://") {
|
||||||
|
// Treat as region: let AWS SDK resolve the correct endpoint
|
||||||
|
// (supports all AWS partitions: aws, aws-cn, aws-us-gov, etc.)
|
||||||
|
opts = append(opts, bedrock.WithRegion(cfg.APIBase))
|
||||||
|
} else {
|
||||||
|
// Full endpoint URL provided (for custom endpoints or testing)
|
||||||
|
opts = append(opts, bedrock.WithBaseEndpoint(cfg.APIBase))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use a separate timeout for AWS config loading (credential resolution can block)
|
||||||
|
initTimeout := 30 * time.Second
|
||||||
|
if cfg.RequestTimeout > 0 {
|
||||||
|
reqTimeout := time.Duration(cfg.RequestTimeout) * time.Second
|
||||||
|
// Set request timeout for API calls
|
||||||
|
opts = append(opts, bedrock.WithRequestTimeout(reqTimeout))
|
||||||
|
// Ensure init timeout is at least as large as request timeout
|
||||||
|
if reqTimeout > initTimeout {
|
||||||
|
initTimeout = reqTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), initTimeout)
|
||||||
|
defer cancel()
|
||||||
|
// Note: AWS_PROFILE env var is automatically used by AWS SDK
|
||||||
|
provider, err := bedrock.NewProvider(ctx, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("creating bedrock provider: %w", err)
|
||||||
|
}
|
||||||
|
return provider, modelID, nil
|
||||||
|
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||||
|
|
|
||||||
|
|
@ -700,3 +700,78 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) {
|
||||||
t.Fatalf("custom_field = %v, want test", got)
|
t.Fatalf("custom_field = %v, want test", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
|
||||||
|
// Set dummy AWS env vars to make test deterministic
|
||||||
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
||||||
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
|
||||||
|
t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
|
||||||
|
// Clear profile-related env vars to avoid loading shared config
|
||||||
|
t.Setenv("AWS_PROFILE", "")
|
||||||
|
t.Setenv("AWS_DEFAULT_PROFILE", "")
|
||||||
|
t.Setenv("AWS_SDK_LOAD_CONFIG", "")
|
||||||
|
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "")
|
||||||
|
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "bedrock-claude",
|
||||||
|
Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
APIBase: "us-west-2", // Region (also sets AWS region)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err == nil {
|
||||||
|
// Provider created successfully (built with -tags bedrock)
|
||||||
|
if provider == nil {
|
||||||
|
t.Error("provider is nil on success")
|
||||||
|
}
|
||||||
|
if modelID != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "us.anthropic.claude-sonnet-4-20250514-v1:0")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errMsg := err.Error()
|
||||||
|
// When built without -tags bedrock, expect stub error
|
||||||
|
if strings.Contains(errMsg, "build with -tags bedrock") {
|
||||||
|
return // Expected stub error
|
||||||
|
}
|
||||||
|
// Unexpected error - fail the test
|
||||||
|
t.Errorf("unexpected error from bedrock provider: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_BedrockWithEndpointURL(t *testing.T) {
|
||||||
|
// Set dummy AWS env vars to make test deterministic
|
||||||
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
||||||
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
|
||||||
|
t.Setenv("AWS_REGION", "us-east-1") // Required when using endpoint URL
|
||||||
|
t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
|
||||||
|
// Clear profile-related env vars to avoid loading shared config
|
||||||
|
t.Setenv("AWS_PROFILE", "")
|
||||||
|
t.Setenv("AWS_DEFAULT_PROFILE", "")
|
||||||
|
t.Setenv("AWS_SDK_LOAD_CONFIG", "")
|
||||||
|
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "")
|
||||||
|
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "bedrock-claude",
|
||||||
|
Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
APIBase: "https://bedrock-runtime.us-east-1.amazonaws.com", // Full endpoint URL
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err == nil {
|
||||||
|
// Provider created successfully (built with -tags bedrock)
|
||||||
|
if provider == nil {
|
||||||
|
t.Error("provider is nil on success")
|
||||||
|
}
|
||||||
|
if modelID != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, "us.anthropic.claude-sonnet-4-20250514-v1:0")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errMsg := err.Error()
|
||||||
|
// When built without -tags bedrock, expect stub error
|
||||||
|
if strings.Contains(errMsg, "build with -tags bedrock") {
|
||||||
|
return // Expected stub error
|
||||||
|
}
|
||||||
|
// Unexpected error - fail the test
|
||||||
|
t.Errorf("unexpected error from bedrock provider: %v", err)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,14 @@ func (r *ToolRegistry) ExecuteWithContext(
|
||||||
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
|
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate arguments against the tool's declared schema.
|
||||||
|
if err := validateToolArgs(tool.Parameters(), args); err != nil {
|
||||||
|
logger.WarnCF("tool", "Tool argument validation failed",
|
||||||
|
map[string]any{"tool": name, "error": err.Error()})
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid arguments for tool %q: %s", name, err)).
|
||||||
|
WithError(fmt.Errorf("argument validation failed: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
// Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx).
|
// Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx).
|
||||||
// Always inject — tools validate what they require.
|
// Always inject — tools validate what they require.
|
||||||
ctx = WithToolContext(ctx, channel, chatID)
|
ctx = WithToolContext(ctx, channel, chatID)
|
||||||
|
|
|
||||||
209
pkg/tools/validate.go
Normal file
209
pkg/tools/validate.go
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateToolArgs validates args against a JSON Schema-like map.
|
||||||
|
// schema is expected to have optional keys: "properties", "required", "additionalProperties".
|
||||||
|
func validateToolArgs(schema map[string]any, args map[string]any) error {
|
||||||
|
if len(schema) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == nil {
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkRequired(schema, args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
propsRaw, ok := schema["properties"]
|
||||||
|
if !ok {
|
||||||
|
return nil // no properties defined — accept any args
|
||||||
|
}
|
||||||
|
|
||||||
|
props, ok := propsRaw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
additional := allowsAdditional(schema)
|
||||||
|
|
||||||
|
for key, val := range args {
|
||||||
|
propSchemaRaw, known := props[key]
|
||||||
|
if !known {
|
||||||
|
if !additional {
|
||||||
|
return fmt.Errorf("unexpected property %q", key)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
propSchema, ok := propSchemaRaw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue // can't validate without a proper schema map
|
||||||
|
}
|
||||||
|
if err := checkType(key, val, propSchema); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRequired verifies that every field listed in schema["required"] is present in args.
|
||||||
|
func checkRequired(schema map[string]any, args map[string]any) error {
|
||||||
|
reqRaw, ok := schema["required"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var required []string
|
||||||
|
|
||||||
|
switch r := reqRaw.(type) {
|
||||||
|
case []string:
|
||||||
|
required = r
|
||||||
|
case []any:
|
||||||
|
for _, v := range r {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if ok {
|
||||||
|
required = append(required, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range required {
|
||||||
|
if _, present := args[field]; !present {
|
||||||
|
return fmt.Errorf("missing required property %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowsAdditional returns true when the schema explicitly sets
|
||||||
|
// "additionalProperties" to true, or when the key is absent (default: reject extras).
|
||||||
|
func allowsAdditional(schema map[string]any) bool {
|
||||||
|
v, ok := schema["additionalProperties"]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
return ok && b
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkType validates that val matches the JSON Schema type declared in propSchema.
|
||||||
|
func checkType(key string, val any, propSchema map[string]any) error {
|
||||||
|
typeRaw, ok := propSchema["type"]
|
||||||
|
if !ok {
|
||||||
|
return nil // no type constraint
|
||||||
|
}
|
||||||
|
typeName, ok := typeRaw.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typeName {
|
||||||
|
case "string":
|
||||||
|
if _, ok := val.(string); !ok {
|
||||||
|
return fmt.Errorf("property %q: expected string, got %T", key, val)
|
||||||
|
}
|
||||||
|
case "integer":
|
||||||
|
switch v := val.(type) {
|
||||||
|
case float64:
|
||||||
|
if v != math.Trunc(v) {
|
||||||
|
return fmt.Errorf("property %q: expected integer, got float64 with fractional part", key)
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
// ok
|
||||||
|
case int64:
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("property %q: expected integer, got %T", key, val)
|
||||||
|
}
|
||||||
|
case "number":
|
||||||
|
switch val.(type) {
|
||||||
|
case float64, int, int64:
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("property %q: expected number, got %T", key, val)
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
if _, ok := val.(bool); !ok {
|
||||||
|
return fmt.Errorf("property %q: expected boolean, got %T", key, val)
|
||||||
|
}
|
||||||
|
case "array":
|
||||||
|
arr, ok := val.([]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("property %q: expected array, got %T", key, val)
|
||||||
|
}
|
||||||
|
if err := checkArrayItems(key, arr, propSchema); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "object":
|
||||||
|
obj, ok := val.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("property %q: expected object, got %T", key, val)
|
||||||
|
}
|
||||||
|
if err := validateToolArgs(propSchema, obj); err != nil {
|
||||||
|
return fmt.Errorf("property %q: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkEnum(key, val, propSchema); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkArrayItems validates each element of arr against the "items" sub-schema.
|
||||||
|
func checkArrayItems(key string, arr []any, propSchema map[string]any) error {
|
||||||
|
itemsRaw, ok := propSchema["items"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
itemSchema, ok := itemsRaw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i, elem := range arr {
|
||||||
|
elemKey := fmt.Sprintf("%s[%d]", key, i)
|
||||||
|
if err := checkType(elemKey, elem, itemSchema); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEnum validates that val is one of the allowed enum values in propSchema.
|
||||||
|
func checkEnum(key string, val any, propSchema map[string]any) error {
|
||||||
|
enumRaw, ok := propSchema["enum"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ev := enumRaw.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, allowed := range ev {
|
||||||
|
if val == allowed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
s, ok := val.(string)
|
||||||
|
if ok {
|
||||||
|
for _, allowed := range ev {
|
||||||
|
if s == allowed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil // unknown enum format, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("property %q: value %v is not in enum", key, val)
|
||||||
|
}
|
||||||
465
pkg/tools/validate_test.go
Normal file
465
pkg/tools/validate_test.go
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure imports are used.
|
||||||
|
var (
|
||||||
|
_ = context.Background
|
||||||
|
_ = strings.Contains
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateToolArgs(t *testing.T) {
|
||||||
|
baseSchema := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"name": map[string]any{"type": "string"},
|
||||||
|
"age": map[string]any{"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": []string{"name"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
schema map[string]any
|
||||||
|
args map[string]any
|
||||||
|
wantErr string // empty means no error expected
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid args all required present",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"name": "alice", "age": float64(30)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing required field",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"age": float64(30)},
|
||||||
|
wantErr: "missing required property \"name\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type string field gets number",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"name": float64(42)},
|
||||||
|
wantErr: "expected string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil args with required fields",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: nil,
|
||||||
|
wantErr: "missing required property \"name\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil args no required fields",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"name": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty args no required fields",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"name": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional field correct type",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"name": "bob", "age": float64(25)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional field wrong type",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"name": "bob", "age": "twenty"},
|
||||||
|
wantErr: "expected integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "integer as float64 no fractional part",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"name": "carol", "age": float64(42)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "actual float for integer field",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"name": "dave", "age": float64(42.5)},
|
||||||
|
wantErr: "expected integer, got float64 with fractional part",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "number type accepts float",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"score": map[string]any{"type": "number"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"score": float64(3.14)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "number type accepts integer",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"score": map[string]any{"type": "number"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"score": float64(10)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean type valid",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"flag": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"flag": true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean type wrong",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"flag": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"flag": "true"},
|
||||||
|
wantErr: "expected boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "required as []any from MCP deserialization",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"cmd": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []any{"cmd"},
|
||||||
|
},
|
||||||
|
args: map[string]any{},
|
||||||
|
wantErr: "missing required property \"cmd\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enum valid value []any",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"color": "red"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enum invalid value []any",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"color": "yellow"},
|
||||||
|
wantErr: "not in enum",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enum valid value []string",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"color": "green"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enum invalid value []string",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"color": "yellow"},
|
||||||
|
wantErr: "not in enum",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra unexpected property rejected",
|
||||||
|
schema: baseSchema,
|
||||||
|
args: map[string]any{"name": "eve", "hobby": "chess"},
|
||||||
|
wantErr: "unexpected property \"hobby\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra property allowed with additionalProperties true",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"name": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"additionalProperties": true,
|
||||||
|
},
|
||||||
|
args: map[string]any{"name": "eve", "hobby": "chess"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested object valid",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"address": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"city": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"city"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{
|
||||||
|
"address": map[string]any{"city": "Berlin"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested object wrong type",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"address": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"city": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"address": "not an object"},
|
||||||
|
wantErr: "expected object",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array with valid element types",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"tags": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"tags": []any{"a", "b", "c"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "array with wrong element types",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"tags": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: map[string]any{"tags": []any{"a", float64(2)}},
|
||||||
|
wantErr: "expected string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "schema with no properties key accepts any args",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
args: map[string]any{"anything": "goes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty schema accepts anything",
|
||||||
|
schema: map[string]any{},
|
||||||
|
args: map[string]any{"foo": "bar"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateToolArgs(tc.schema, tc.args)
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateToolArgs_RegistryIntegration(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(&mockRegistryTool{
|
||||||
|
name: "read_file",
|
||||||
|
desc: "reads a file",
|
||||||
|
params: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
result: SilentResult("file contents"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Valid args — should succeed
|
||||||
|
result := r.Execute(context.Background(), "read_file", map[string]any{"path": "/tmp/x"})
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing required field — should fail with validation error
|
||||||
|
result = r.Execute(context.Background(), "read_file", map[string]any{})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected validation error for missing required field")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "missing required p") {
|
||||||
|
t.Errorf("expected 'missing required p...' in error, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.Err == nil {
|
||||||
|
t.Error("expected Err to be set via WithError")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong type — should fail with validation error
|
||||||
|
result = r.Execute(context.Background(), "read_file", map[string]any{"path": 123.0})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected validation error for wrong type")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "expected string") {
|
||||||
|
t.Errorf("expected 'expected string' in error, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra property — should fail with validation error
|
||||||
|
result = r.Execute(context.Background(), "read_file", map[string]any{"path": "/x", "__inject": true})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected validation error for extra property")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "unexpected prop") {
|
||||||
|
t.Errorf("expected 'unexpected prop...' in error, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateToolArgs_RealSchemas(t *testing.T) {
|
||||||
|
execSchema := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
"working_dir": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cronSchema := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"action": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []any{"add", "list", "remove", "enable", "disable"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
|
||||||
|
webSearchSchema := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"query": map[string]any{"type": "string"},
|
||||||
|
"count": map[string]any{"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": []string{"query"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
schema map[string]any
|
||||||
|
args map[string]any
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
// ExecTool
|
||||||
|
{
|
||||||
|
name: "exec valid args",
|
||||||
|
schema: execSchema,
|
||||||
|
args: map[string]any{"command": "ls -la", "working_dir": "/tmp"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exec missing required command",
|
||||||
|
schema: execSchema,
|
||||||
|
args: map[string]any{"working_dir": "/tmp"},
|
||||||
|
wantErr: "missing required property \"command\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exec wrong type for command",
|
||||||
|
schema: execSchema,
|
||||||
|
args: map[string]any{"command": float64(123)},
|
||||||
|
wantErr: "expected string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exec extra injected arg",
|
||||||
|
schema: execSchema,
|
||||||
|
args: map[string]any{"command": "ls", "malicious": "payload"},
|
||||||
|
wantErr: "unexpected property \"malicious\"",
|
||||||
|
},
|
||||||
|
|
||||||
|
// CronTool
|
||||||
|
{
|
||||||
|
name: "cron valid enum value",
|
||||||
|
schema: cronSchema,
|
||||||
|
args: map[string]any{"action": "add"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cron invalid enum value",
|
||||||
|
schema: cronSchema,
|
||||||
|
args: map[string]any{"action": "destroy"},
|
||||||
|
wantErr: "not in enum",
|
||||||
|
},
|
||||||
|
|
||||||
|
// WebSearchTool
|
||||||
|
{
|
||||||
|
name: "websearch valid args",
|
||||||
|
schema: webSearchSchema,
|
||||||
|
args: map[string]any{"query": "golang testing", "count": float64(10)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "websearch missing required query",
|
||||||
|
schema: webSearchSchema,
|
||||||
|
args: map[string]any{"count": float64(5)},
|
||||||
|
wantErr: "missing required property \"query\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "websearch wrong type for count",
|
||||||
|
schema: webSearchSchema,
|
||||||
|
args: map[string]any{"query": "test", "count": "ten"},
|
||||||
|
wantErr: "expected integer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateToolArgs(tc.schema, tc.args)
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
141
pkg/voice/elevenlabs_transcriber.go
Normal file
141
pkg/voice/elevenlabs_transcriber.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ElevenLabsTranscriber uses the ElevenLabs Scribe API for speech-to-text.
|
||||||
|
type ElevenLabsTranscriber struct {
|
||||||
|
apiKey string
|
||||||
|
apiBase string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewElevenLabsTranscriber(apiKey string) *ElevenLabsTranscriber {
|
||||||
|
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
|
||||||
|
|
||||||
|
return &ElevenLabsTranscriber{
|
||||||
|
apiKey: apiKey,
|
||||||
|
apiBase: "https://api.elevenlabs.io",
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 120 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
|
||||||
|
logger.InfoCF("voice", "Starting ElevenLabs transcription", map[string]any{"audio_file": audioFilePath})
|
||||||
|
|
||||||
|
audioFile, err := os.Open(audioFilePath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
|
||||||
|
return nil, fmt.Errorf("failed to open audio file: %w", err)
|
||||||
|
}
|
||||||
|
defer audioFile.Close()
|
||||||
|
|
||||||
|
fileInfo, err := audioFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("voice", "Audio file details", map[string]any{
|
||||||
|
"size_bytes": fileInfo.Size(),
|
||||||
|
"file_name": filepath.Base(audioFilePath),
|
||||||
|
})
|
||||||
|
|
||||||
|
var requestBody bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&requestBody)
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
|
||||||
|
return nil, fmt.Errorf("failed to create form file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = io.Copy(part, audioFile); err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
|
||||||
|
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = writer.WriteField("model_id", "scribe_v1"); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write model_id field: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
|
||||||
|
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := t.apiBase + "/v1/speech-to-text"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set("Xi-Api-Key", t.apiKey)
|
||||||
|
|
||||||
|
logger.DebugCF("voice", "Sending transcription request to ElevenLabs API", map[string]any{
|
||||||
|
"url": url,
|
||||||
|
"request_size_bytes": requestBody.Len(),
|
||||||
|
"file_size_bytes": fileInfo.Size(),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.ErrorCF("voice", "ElevenLabs API error", map[string]any{
|
||||||
|
"status_code": resp.StatusCode,
|
||||||
|
"response": string(body),
|
||||||
|
})
|
||||||
|
return nil, fmt.Errorf("ElevenLabs API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("voice", "Received response from ElevenLabs API", map[string]any{
|
||||||
|
"status_code": resp.StatusCode,
|
||||||
|
"response_size_bytes": len(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
var result TranscriptionResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("voice", "ElevenLabs transcription completed successfully", map[string]any{
|
||||||
|
"text_length": len(result.Text),
|
||||||
|
"language": result.Language,
|
||||||
|
"transcription_preview": utils.Truncate(result.Text, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ElevenLabsTranscriber) Name() string {
|
||||||
|
return "elevenlabs"
|
||||||
|
}
|
||||||
83
pkg/voice/elevenlabs_transcriber_test.go
Normal file
83
pkg/voice/elevenlabs_transcriber_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package voice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure ElevenLabsTranscriber satisfies the Transcriber interface at compile time.
|
||||||
|
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
||||||
|
|
||||||
|
func TestElevenLabsTranscriberName(t *testing.T) {
|
||||||
|
tr := NewElevenLabsTranscriber("sk_test")
|
||||||
|
if got := tr.Name(); got != "elevenlabs" {
|
||||||
|
t.Errorf("Name() = %q, want %q", got, "elevenlabs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
audioPath := filepath.Join(tmpDir, "clip.ogg")
|
||||||
|
if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write fake audio file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/speech-to-text" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Xi-Api-Key") != "sk_test" {
|
||||||
|
t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
||||||
|
Text: "hello from elevenlabs",
|
||||||
|
Language: "en",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tr := NewElevenLabsTranscriber("sk_test")
|
||||||
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
|
resp, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Transcribe() error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Text != "hello from elevenlabs" {
|
||||||
|
t.Errorf("Text = %q, want %q", resp.Text, "hello from elevenlabs")
|
||||||
|
}
|
||||||
|
if resp.Language != "en" {
|
||||||
|
t.Errorf("Language = %q, want %q", resp.Language, "en")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("api error", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tr := NewElevenLabsTranscriber("sk_bad")
|
||||||
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
|
_, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-200 response, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
tr := NewElevenLabsTranscriber("sk_test")
|
||||||
|
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,10 @@ func DetectTranscriber(cfg *config.Config) Transcriber {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ElevenLabs voice config (supports Scribe STT).
|
||||||
|
if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" {
|
||||||
|
return NewElevenLabsTranscriber(key)
|
||||||
|
}
|
||||||
// Fall back to any model-list entry that uses the groq/ protocol.
|
// Fall back to any model-list entry that uses the groq/ protocol.
|
||||||
for _, mc := range cfg.ModelList {
|
for _, mc := range cfg.ModelList {
|
||||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
|
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,48 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
}),
|
}),
|
||||||
wantNil: true,
|
wantNil: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "elevenlabs voice config key",
|
||||||
|
cfg: &config.Config{
|
||||||
|
Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"},
|
||||||
|
},
|
||||||
|
wantName: "elevenlabs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "elevenlabs takes priority over groq model list",
|
||||||
|
cfg: (&config.Config{
|
||||||
|
Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"},
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
||||||
|
},
|
||||||
|
}).WithSecurity(&config.SecurityConfig{
|
||||||
|
ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"groq": {
|
||||||
|
APIKeys: []string{"sk-groq-direct"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
wantName: "elevenlabs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "voice model name takes priority over elevenlabs",
|
||||||
|
cfg: (&config.Config{
|
||||||
|
Voice: config.VoiceConfig{
|
||||||
|
ModelName: "voice-gemini",
|
||||||
|
ElevenLabsAPIKey: "sk_elevenlabs_test",
|
||||||
|
},
|
||||||
|
ModelList: []*config.ModelConfig{
|
||||||
|
{ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"},
|
||||||
|
},
|
||||||
|
}).WithSecurity(&config.SecurityConfig{
|
||||||
|
ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"voice-gemini": {
|
||||||
|
APIKeys: []string{"sk-gemini-model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
wantName: "audio-model",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type channelCatalogItem struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelCatalog = []channelCatalogItem{
|
var channelCatalog = []channelCatalogItem{
|
||||||
|
{Name: "weixin", ConfigKey: "weixin"},
|
||||||
{Name: "telegram", ConfigKey: "telegram"},
|
{Name: "telegram", ConfigKey: "telegram"},
|
||||||
{Name: "discord", ConfigKey: "discord"},
|
{Name: "discord", ConfigKey: "discord"},
|
||||||
{Name: "slack", ConfigKey: "slack"},
|
{Name: "slack", ConfigKey: "slack"},
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,15 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load existing config and copy security credentials before validation,
|
||||||
|
// so that security-managed fields (e.g. pico token) are available.
|
||||||
|
oldCfg, err := config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.SecurityCopyFrom(oldCfg)
|
||||||
|
|
||||||
if errs := validateConfig(&cfg); len(errs) > 0 {
|
if errs := validateConfig(&cfg); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
@ -64,13 +73,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("new config: %+v", cfg)
|
logger.Infof("configuration updated successfully")
|
||||||
oldCfg, err := config.LoadConfig(h.configPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg.SecurityCopyFrom(oldCfg)
|
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
@ -149,6 +152,14 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore security fields (tokens/keys) from the loaded config before validation,
|
||||||
|
// because private fields are lost during JSON round-trip.
|
||||||
|
newCfg.SecurityCopyFrom(cfg)
|
||||||
|
if err := newCfg.ApplySecurity(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
@ -159,8 +170,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newCfg.SecurityCopyFrom(cfg)
|
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -141,6 +143,120 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and
|
||||||
|
// its token stored only in .security.yml (not in the JSON payload).
|
||||||
|
func setupPicoEnabledEnv(t *testing.T) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
oldPicoHome := os.Getenv("PICOCLAW_HOME")
|
||||||
|
|
||||||
|
if err := os.Setenv("HOME", tmp); err != nil {
|
||||||
|
t.Fatalf("set HOME: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil {
|
||||||
|
t.Fatalf("set PICOCLAW_HOME: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
|
ModelName: "custom-default",
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
}}
|
||||||
|
cfg.Agents.Defaults.ModelName = "custom-default"
|
||||||
|
cfg.Channels.Pico.Enabled = true
|
||||||
|
cfg.WithSecurity(&config.SecurityConfig{
|
||||||
|
ModelList: map[string]config.ModelSecurityEntry{
|
||||||
|
"custom-default": {APIKeys: []string{"sk-default"}},
|
||||||
|
},
|
||||||
|
Channels: &config.ChannelsSecurity{
|
||||||
|
Pico: &config.PicoSecurity{Token: "test-pico-token"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmp, "config.json")
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.Setenv("HOME", oldHome)
|
||||||
|
if oldPicoHome == "" {
|
||||||
|
_ = os.Unsetenv("PICOCLAW_HOME")
|
||||||
|
} else {
|
||||||
|
_ = os.Setenv("PICOCLAW_HOME", oldPicoHome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configPath, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
|
||||||
|
configPath, cleanup := setupPicoEnabledEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// PUT request with pico enabled but no token in JSON — token is in .security.yml
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"version": 1,
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"model_name": "custom-default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"pico": {
|
||||||
|
"enabled": true,
|
||||||
|
"ping_interval": 30,
|
||||||
|
"read_timeout": 60,
|
||||||
|
"write_timeout": 10,
|
||||||
|
"max_connections": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "custom-default",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"api_keys": ["sk-default"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
|
||||||
|
configPath, cleanup := setupPicoEnabledEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// PATCH request changing an unrelated field — pico token still in .security.yml
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"gateway": {
|
||||||
|
"log_level": "info"
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||||
gateway.logs.Reset()
|
gateway.logs.Reset()
|
||||||
|
|
||||||
// Ensure Pico Channel is configured before starting gateway
|
// Ensure Pico Channel is configured before starting gateway
|
||||||
if _, err := h.ensurePicoChannel(""); err != nil {
|
if _, err := h.EnsurePicoChannel(""); err != nil {
|
||||||
logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err))
|
logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err))
|
||||||
// Non-fatal: gateway can still start without pico channel
|
// Non-fatal: gateway can still start without pico channel
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerModelRoutes binds model list management endpoints to the ServeMux.
|
// registerModelRoutes binds model list management endpoints to the ServeMux.
|
||||||
|
|
@ -107,7 +108,12 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
var mc config.ModelConfig
|
type custom struct {
|
||||||
|
config.ModelConfig
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var mc custom
|
||||||
if err = json.Unmarshal(body, &mc); err != nil {
|
if err = json.Unmarshal(body, &mc); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -118,13 +124,17 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mc.APIKey != "" {
|
||||||
|
mc.ModelConfig.SetAPIKey(mc.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.ModelList = append(cfg.ModelList, &mc)
|
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
@ -158,7 +168,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
var mc config.ModelConfig
|
type custom struct {
|
||||||
|
config.ModelConfig
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var mc custom
|
||||||
if err = json.Unmarshal(body, &mc); err != nil {
|
if err = json.Unmarshal(body, &mc); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -182,14 +197,18 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Preserve the existing API key when the caller omits it (empty string).
|
// Preserve the existing API key when the caller omits it (empty string).
|
||||||
// This lets the UI update api_base / proxy without clearing the stored secret.
|
// This lets the UI update api_base / proxy without clearing the stored secret.
|
||||||
if mc.APIKey() == "" {
|
if mc.APIKey == "" {
|
||||||
mc.SetAPIKey(cfg.ModelList[idx].APIKey())
|
mc.ModelConfig.SetAPIKey(cfg.ModelList[idx].APIKey())
|
||||||
|
} else {
|
||||||
|
mc.ModelConfig.SetAPIKey(mc.APIKey)
|
||||||
}
|
}
|
||||||
if mc.ExtraBody == nil {
|
if mc.ExtraBody == nil {
|
||||||
mc.ExtraBody = cfg.ModelList[idx].ExtraBody
|
mc.ExtraBody = cfg.ModelList[idx].ExtraBody
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.ModelList[idx] = &mc
|
cfg.ModelList[idx] = &mc.ModelConfig
|
||||||
|
|
||||||
|
logger.Debugf("update model config: %#v", mc.ModelConfig)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
@ -297,16 +316,25 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maskAPIKey returns a masked version of an API key for safe display.
|
// maskAPIKey returns a masked version of an API key for safe display.
|
||||||
// Keys longer than 8 chars show prefix + last 4 chars: "sk-****abcd"
|
// Keys longer than 12 chars show prefix + last 4 chars: "sk-****abcd".
|
||||||
|
// Keys 9-12 chars show prefix + last 2 chars: "sk-****cd".
|
||||||
// Shorter keys are fully masked as "****".
|
// Shorter keys are fully masked as "****".
|
||||||
// Empty keys return empty string.
|
// Empty keys return empty string.
|
||||||
|
// Ensure at least 40% of the key will not be displayed.
|
||||||
func maskAPIKey(key string) string {
|
func maskAPIKey(key string) string {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(key) <= 8 {
|
if len(key) <= 8 {
|
||||||
return "****"
|
return "****"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show first 3 chars and last 2 chars
|
||||||
|
if len(key) <= 12 {
|
||||||
|
return key[:3] + "****" + key[len(key)-2:]
|
||||||
|
}
|
||||||
|
|
||||||
// Show first 3 chars and last 4 chars
|
// Show first 3 chars and last 4 chars
|
||||||
return key[:3] + "****" + key[len(key)-4:]
|
return key[:3] + "****" + key[len(key)-4:]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -315,3 +317,112 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
|
||||||
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
|
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||||
|
"model_name":"new-model",
|
||||||
|
"model":"openai/gpt-4o-mini",
|
||||||
|
"api_key":"sk-new-model-key"
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.ModelList) != 2 {
|
||||||
|
t.Fatalf("len(model_list) = %d, want 2", len(cfg.ModelList))
|
||||||
|
}
|
||||||
|
|
||||||
|
added := cfg.ModelList[1]
|
||||||
|
if added.ModelName != "new-model" {
|
||||||
|
t.Fatalf("model_name = %q, want %q", added.ModelName, "new-model")
|
||||||
|
}
|
||||||
|
if added.APIKey() != "sk-new-model-key" {
|
||||||
|
t.Fatalf("api_key = %q, want %q", added.APIKey(), "sk-new-model-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaskAPIKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty key",
|
||||||
|
key: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short key fully masked",
|
||||||
|
key: "abcd",
|
||||||
|
want: "****",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "length 8 boundary fully masked",
|
||||||
|
key: "12345678",
|
||||||
|
want: "****",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "length 9 boundary shows last 2",
|
||||||
|
key: "123456789",
|
||||||
|
want: "123****89",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "length 12 boundary shows last 2",
|
||||||
|
key: "abcdefghijkl",
|
||||||
|
want: "abc****kl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "length 13 boundary shows last 4",
|
||||||
|
key: "abcdefghijklm",
|
||||||
|
want: "abc****jklm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typical api key",
|
||||||
|
key: "sk-1234567890abcd",
|
||||||
|
want: "sk-****abcd",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := maskAPIKey(tc.key)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("maskAPIKey(%q) = %q, want %q", tc.key, got, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.key != "" {
|
||||||
|
displayed := strings.Replace(tc.want, "****", "", 1)
|
||||||
|
if len(tc.key) <= 8 {
|
||||||
|
if displayed != "" {
|
||||||
|
t.Fatalf("maskAPIKey(%q) displayed part = %q, want empty", tc.key, displayed)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(displayed)*10 > len(tc.key)*6 {
|
||||||
|
t.Fatalf(
|
||||||
|
"maskAPIKey(%q) displayed length = %d, want at most 60%% of %d",
|
||||||
|
tc.key,
|
||||||
|
len(displayed),
|
||||||
|
len(tc.key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,14 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensurePicoChannel enables the Pico channel with sane defaults if it isn't
|
// EnsurePicoChannel enables the Pico channel with sane defaults if it isn't
|
||||||
// already configured. Returns true when the config was modified.
|
// already configured. Returns true when the config was modified.
|
||||||
//
|
//
|
||||||
// callerOrigin is the Origin header from the setup request. If non-empty and
|
// callerOrigin is the Origin header from the setup request. If non-empty and
|
||||||
// no origins are configured yet, it's written as the allowed origin so the
|
// no origins are configured yet, it's written as the allowed origin so the
|
||||||
// WebSocket handshake works for whatever host the caller is on (LAN, custom
|
// WebSocket handshake works for whatever host the caller is on (LAN, custom
|
||||||
// port, etc.). Pass "" when there's no request context.
|
// port, etc.). Pass "" when there's no request context.
|
||||||
func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) {
|
func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to load config: %w", err)
|
return false, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
|
@ -134,7 +134,7 @@ func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) {
|
||||||
//
|
//
|
||||||
// POST /api/pico/setup
|
// POST /api/pico/setup
|
||||||
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
changed, err := h.ensurePicoChannel(r.Header.Get("Origin"))
|
changed, err := h.EnsurePicoChannel(r.Header.Get("Origin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -17,12 +18,12 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
changed, err := h.ensurePicoChannel("")
|
changed, err := h.EnsurePicoChannel("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
if !changed {
|
if !changed {
|
||||||
t.Fatal("ensurePicoChannel() should report changed on a fresh config")
|
t.Fatal("EnsurePicoChannel() should report changed on a fresh config")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
|
@ -42,8 +43,8 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
if _, err := h.ensurePicoChannel(""); err != nil {
|
if _, err := h.EnsurePicoChannel(""); err != nil {
|
||||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
|
@ -60,8 +61,8 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
if _, err := h.ensurePicoChannel("http://localhost:18800"); err != nil {
|
if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil {
|
||||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
|
@ -80,8 +81,8 @@ func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
if _, err := h.ensurePicoChannel(""); err != nil {
|
if _, err := h.EnsurePicoChannel(""); err != nil {
|
||||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
|
@ -101,8 +102,8 @@ func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) {
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
lanOrigin := "http://192.168.1.9:18800"
|
lanOrigin := "http://192.168.1.9:18800"
|
||||||
if _, err := h.ensurePicoChannel(lanOrigin); err != nil {
|
if _, err := h.EnsurePicoChannel(lanOrigin); err != nil {
|
||||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
|
@ -130,12 +131,12 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
|
||||||
|
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
changed, err := h.ensurePicoChannel("")
|
changed, err := h.EnsurePicoChannel("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ensurePicoChannel() error = %v", err)
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
t.Error("ensurePicoChannel() should not change a fully configured config")
|
t.Error("EnsurePicoChannel() should not change a fully configured config")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err = config.LoadConfig(configPath)
|
cfg, err = config.LoadConfig(configPath)
|
||||||
|
|
@ -154,6 +155,71 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
raw, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if err = os.WriteFile(configPath, raw, 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
changed, err := h.EnsurePicoChannel("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("EnsurePicoChannel() should report changed when pico is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.Channels.Pico.Enabled {
|
||||||
|
t.Error("expected Pico to be enabled after setup")
|
||||||
|
}
|
||||||
|
if cfg.Channels.Pico.Token() == "" {
|
||||||
|
t.Error("expected a non-empty token after setup")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), config.SecurityConfigFile)); err != nil {
|
||||||
|
t.Fatalf("expected .security.yml to be created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ModelName = ""
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
if _, err := h.EnsurePicoChannel(""); err != nil {
|
||||||
|
t.Fatalf("EnsurePicoChannel() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.Channels.Pico.Enabled {
|
||||||
|
t.Error("expected Pico to be enabled after launcher startup setup")
|
||||||
|
}
|
||||||
|
if cfg.Channels.Pico.Token() == "" {
|
||||||
|
t.Error("expected a non-empty token after launcher startup setup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
@ -161,20 +227,20 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||||
origin := "http://localhost:18800"
|
origin := "http://localhost:18800"
|
||||||
|
|
||||||
// First call sets things up
|
// First call sets things up
|
||||||
if _, err := h.ensurePicoChannel(origin); err != nil {
|
if _, err := h.EnsurePicoChannel(origin); err != nil {
|
||||||
t.Fatalf("first ensurePicoChannel() error = %v", err)
|
t.Fatalf("first EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg1, _ := config.LoadConfig(configPath)
|
cfg1, _ := config.LoadConfig(configPath)
|
||||||
token1 := cfg1.Channels.Pico.Token()
|
token1 := cfg1.Channels.Pico.Token()
|
||||||
|
|
||||||
// Second call should be a no-op
|
// Second call should be a no-op
|
||||||
changed, err := h.ensurePicoChannel(origin)
|
changed, err := h.EnsurePicoChannel(origin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second ensurePicoChannel() error = %v", err)
|
t.Fatalf("second EnsurePicoChannel() error = %v", err)
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
t.Error("second ensurePicoChannel() should not report changed")
|
t.Error("second EnsurePicoChannel() should not report changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg2, _ := config.LoadConfig(configPath)
|
cfg2, _ := config.LoadConfig(configPath)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ type Handler struct {
|
||||||
oauthMu sync.Mutex
|
oauthMu sync.Mutex
|
||||||
oauthFlows map[string]*oauthFlow
|
oauthFlows map[string]*oauthFlow
|
||||||
oauthState map[string]string
|
oauthState map[string]string
|
||||||
|
weixinMu sync.Mutex
|
||||||
|
weixinFlows map[string]*weixinFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates an instance of the API handler.
|
// NewHandler creates an instance of the API handler.
|
||||||
|
|
@ -26,6 +28,7 @@ func NewHandler(configPath string) *Handler {
|
||||||
serverPort: launcherconfig.DefaultPort,
|
serverPort: launcherconfig.DefaultPort,
|
||||||
oauthFlows: make(map[string]*oauthFlow),
|
oauthFlows: make(map[string]*oauthFlow),
|
||||||
oauthState: make(map[string]string),
|
oauthState: make(map[string]string),
|
||||||
|
weixinFlows: make(map[string]*weixinFlow),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +72,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
|
||||||
// Launcher service parameters (port/public)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
h.registerLauncherConfigRoutes(mux)
|
||||||
|
|
||||||
|
// WeChat QR login flow
|
||||||
|
h.registerWeixinRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
|
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
|
||||||
|
|
|
||||||
317
web/backend/api/weixin.go
Normal file
317
web/backend/api/weixin.go
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"rsc.io/qr"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels/weixin"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
weixinFlowTTL = 5 * time.Minute
|
||||||
|
weixinFlowGCAge = 30 * time.Minute
|
||||||
|
weixinBaseURL = "https://ilinkai.weixin.qq.com/"
|
||||||
|
weixinBotType = "3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
weixinStatusWait = "wait"
|
||||||
|
weixinStatusScanned = "scaned"
|
||||||
|
weixinStatusConfirmed = "confirmed"
|
||||||
|
weixinStatusExpired = "expired"
|
||||||
|
weixinStatusError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type weixinFlow struct {
|
||||||
|
ID 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
|
||||||
|
Status string // wait / scaned / confirmed / expired / error
|
||||||
|
Error string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type weixinFlowResponse struct {
|
||||||
|
FlowID string `json:"flow_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
QRDataURI string `json:"qr_data_uri,omitempty"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerWeixinRoutes binds WeChat QR login endpoints to the ServeMux.
|
||||||
|
func (h *Handler) registerWeixinRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("POST /api/weixin/flows", h.handleStartWeixinFlow)
|
||||||
|
mux.HandleFunc("GET /api/weixin/flows/{id}", h.handlePollWeixinFlow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStartWeixinFlow starts a new WeChat QR login flow.
|
||||||
|
//
|
||||||
|
// POST /api/weixin/flows
|
||||||
|
func (h *Handler) handleStartWeixinFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
api, err := weixin.NewApiClient(weixinBaseURL, "", "")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to create weixin client: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qrResp, err := api.GetQRCode(ctx, weixinBotType)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to get QR code: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataURI, err := generateQRDataURI(qrResp.QrcodeImgContent)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to generate QR image: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
flow := &weixinFlow{
|
||||||
|
ID: newWeixinFlowID(),
|
||||||
|
Qrcode: qrResp.Qrcode,
|
||||||
|
QRDataURI: dataURI,
|
||||||
|
Status: weixinStatusWait,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ExpiresAt: now.Add(weixinFlowTTL),
|
||||||
|
}
|
||||||
|
h.storeWeixinFlow(flow)
|
||||||
|
|
||||||
|
logger.InfoCF("weixin", "QR flow started", map[string]any{"flow_id": flow.ID})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(weixinFlowResponse{
|
||||||
|
FlowID: flow.ID,
|
||||||
|
Status: flow.Status,
|
||||||
|
QRDataURI: flow.QRDataURI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePollWeixinFlow polls the WeChat API for QR code status and updates the flow.
|
||||||
|
//
|
||||||
|
// GET /api/weixin/flows/{id}
|
||||||
|
func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flowID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if flowID == "" {
|
||||||
|
http.Error(w, "missing flow id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flow, ok := h.getWeixinFlow(flowID)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "flow not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return terminal states directly without polling WeChat again
|
||||||
|
if flow.Status == weixinStatusConfirmed ||
|
||||||
|
flow.Status == weixinStatusExpired ||
|
||||||
|
flow.Status == weixinStatusError {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(weixinFlowResponse{
|
||||||
|
FlowID: flow.ID,
|
||||||
|
Status: flow.Status,
|
||||||
|
Error: flow.Error,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
api, err := weixin.NewApiClient(weixinBaseURL, "", "")
|
||||||
|
if err != nil {
|
||||||
|
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})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, err := api.GetQRCodeStatus(ctx, flow.Qrcode)
|
||||||
|
if err != nil {
|
||||||
|
// Transient error — keep current status, return it
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(weixinFlowResponse{
|
||||||
|
FlowID: flow.ID,
|
||||||
|
Status: flow.Status,
|
||||||
|
QRDataURI: flow.QRDataURI,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch statusResp.Status {
|
||||||
|
case weixinStatusWait:
|
||||||
|
// no change
|
||||||
|
|
||||||
|
case weixinStatusScanned:
|
||||||
|
h.updateWeixinFlowStatus(flowID, weixinStatusScanned)
|
||||||
|
|
||||||
|
case weixinStatusConfirmed:
|
||||||
|
if statusResp.BotToken == "" {
|
||||||
|
h.setWeixinFlowError(flowID, "login confirmed but missing bot_token")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if saveErr := h.saveWeixinBinding(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
|
||||||
|
}
|
||||||
|
h.setWeixinFlowConfirmed(flowID, statusResp.IlinkBotID)
|
||||||
|
logger.InfoCF("weixin", "QR login confirmed, token saved", map[string]any{
|
||||||
|
"flow_id": flowID,
|
||||||
|
"account_id": statusResp.IlinkBotID,
|
||||||
|
})
|
||||||
|
|
||||||
|
case weixinStatusExpired:
|
||||||
|
h.updateWeixinFlowStatus(flowID, weixinStatusExpired)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// unknown status, keep as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
flow, _ = h.getWeixinFlow(flowID)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
resp := weixinFlowResponse{
|
||||||
|
FlowID: flow.ID,
|
||||||
|
Status: flow.Status,
|
||||||
|
AccountID: flow.AccountID,
|
||||||
|
Error: flow.Error,
|
||||||
|
}
|
||||||
|
if flow.Status == weixinStatusWait || flow.Status == weixinStatusScanned {
|
||||||
|
resp.QRDataURI = flow.QRDataURI
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Channels.Weixin.SetToken(token)
|
||||||
|
cfg.Channels.Weixin.Enabled = true
|
||||||
|
if accountID != "" {
|
||||||
|
cfg.Channels.Weixin.AccountID = accountID
|
||||||
|
}
|
||||||
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := h.gatewayStatusData()
|
||||||
|
gatewayStatus, _ := status["gateway_status"].(string)
|
||||||
|
if gatewayStatus != "running" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.RestartGateway(); err != nil {
|
||||||
|
logger.ErrorCF("weixin", "failed to restart gateway after saving binding", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateQRDataURI encodes content as a QR code PNG and returns a data URI.
|
||||||
|
func generateQRDataURI(content string) (string, error) {
|
||||||
|
code, err := qr.Encode(content, qr.L)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("qr encode: %w", err)
|
||||||
|
}
|
||||||
|
pngBytes := code.PNG()
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(pngBytes)
|
||||||
|
return "data:image/png;base64," + encoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWeixinFlowID() string {
|
||||||
|
buf := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("wx_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return "wx_" + hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) storeWeixinFlow(flow *weixinFlow) {
|
||||||
|
h.weixinMu.Lock()
|
||||||
|
defer h.weixinMu.Unlock()
|
||||||
|
h.gcWeixinFlowsLocked(time.Now())
|
||||||
|
h.weixinFlows[flow.ID] = flow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getWeixinFlow(flowID string) (*weixinFlow, bool) {
|
||||||
|
h.weixinMu.Lock()
|
||||||
|
defer h.weixinMu.Unlock()
|
||||||
|
h.gcWeixinFlowsLocked(time.Now())
|
||||||
|
flow, ok := h.weixinFlows[flowID]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
cp := *flow
|
||||||
|
return &cp, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateWeixinFlowStatus(flowID, status string) {
|
||||||
|
h.weixinMu.Lock()
|
||||||
|
defer h.weixinMu.Unlock()
|
||||||
|
if flow, ok := h.weixinFlows[flowID]; ok {
|
||||||
|
flow.Status = status
|
||||||
|
flow.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setWeixinFlowConfirmed(flowID, accountID string) {
|
||||||
|
h.weixinMu.Lock()
|
||||||
|
defer h.weixinMu.Unlock()
|
||||||
|
if flow, ok := h.weixinFlows[flowID]; ok {
|
||||||
|
flow.Status = weixinStatusConfirmed
|
||||||
|
flow.AccountID = accountID
|
||||||
|
flow.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setWeixinFlowError(flowID, errMsg string) {
|
||||||
|
h.weixinMu.Lock()
|
||||||
|
defer h.weixinMu.Unlock()
|
||||||
|
if flow, ok := h.weixinFlows[flowID]; ok {
|
||||||
|
flow.Status = weixinStatusError
|
||||||
|
flow.Error = errMsg
|
||||||
|
flow.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) gcWeixinFlowsLocked(now time.Time) {
|
||||||
|
for id, flow := range h.weixinFlows {
|
||||||
|
if flow.Status == weixinStatusWait || flow.Status == weixinStatusScanned {
|
||||||
|
if !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) {
|
||||||
|
flow.Status = weixinStatusExpired
|
||||||
|
flow.UpdatedAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flow.Status != weixinStatusWait &&
|
||||||
|
flow.Status != weixinStatusScanned &&
|
||||||
|
now.Sub(flow.UpdatedAt) > weixinFlowGCAge {
|
||||||
|
delete(h.weixinFlows, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
web/backend/api/weixin_test.go
Normal file
56
web/backend/api/weixin_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalHealthGet := gatewayHealthGet
|
||||||
|
gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(
|
||||||
|
`{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(os.Getpid()) + `}`,
|
||||||
|
)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
gatewayHealthGet = originalHealthGet
|
||||||
|
})
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
if err := h.saveWeixinBinding("bot-token", "bot-account"); err != nil {
|
||||||
|
t.Fatalf("saveWeixinBinding() error = %v, want nil after config save succeeds", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
savedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := savedCfg.Channels.Weixin.Token(); got != "bot-token" {
|
||||||
|
t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token")
|
||||||
|
}
|
||||||
|
if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {
|
||||||
|
t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account")
|
||||||
|
}
|
||||||
|
if !savedCfg.Channels.Weixin.Enabled {
|
||||||
|
t.Fatalf("Weixin.Enabled = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -169,6 +169,9 @@ func main() {
|
||||||
|
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler = api.NewHandler(absPath)
|
apiHandler = api.NewHandler(absPath)
|
||||||
|
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
||||||
|
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
||||||
|
}
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,4 +62,26 @@ export async function patchAppConfig(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WeChat QR login flow API
|
||||||
|
|
||||||
|
export interface WeixinFlowResponse {
|
||||||
|
flow_id: string
|
||||||
|
status: "wait" | "scaned" | "confirmed" | "expired" | "error"
|
||||||
|
qr_data_uri?: string
|
||||||
|
account_id?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWeixinFlow(): Promise<WeixinFlowResponse> {
|
||||||
|
return request<WeixinFlowResponse>("/api/weixin/flows", { method: "POST" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollWeixinFlow(
|
||||||
|
flowID: string,
|
||||||
|
): Promise<WeixinFlowResponse> {
|
||||||
|
return request<WeixinFlowResponse>(
|
||||||
|
`/api/weixin/flows/${encodeURIComponent(flowID)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export type { ChannelsCatalogResponse, ConfigActionResponse }
|
export type { ChannelsCatalogResponse, ConfigActionResponse }
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,17 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const routerState = useRouterState()
|
const routerState = useRouterState()
|
||||||
const { t } = useTranslation()
|
const { i18n, t } = useTranslation()
|
||||||
const currentPath = routerState.location.pathname
|
const currentPath = routerState.location.pathname
|
||||||
const {
|
const {
|
||||||
channelItems,
|
channelItems,
|
||||||
hasMoreChannels,
|
hasMoreChannels,
|
||||||
showAllChannels,
|
showAllChannels,
|
||||||
toggleShowAllChannels,
|
toggleShowAllChannels,
|
||||||
} = useSidebarChannels({ t })
|
} = useSidebarChannels({
|
||||||
|
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
|
||||||
|
t,
|
||||||
|
})
|
||||||
|
|
||||||
const navGroups: NavGroup[] = React.useMemo(() => {
|
const navGroups: NavGroup[] = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useAtomValue } from "jotai"
|
|
||||||
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"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ChannelConfig,
|
type ChannelConfig,
|
||||||
|
|
@ -17,10 +15,12 @@ import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||||
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
import { GenericForm } from "@/components/channels/channel-forms/generic-form"
|
||||||
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
import { TelegramForm } from "@/components/channels/channel-forms/telegram-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 { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
import { useGateway } from "@/hooks/use-gateway"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
interface ChannelConfigPageProps {
|
interface ChannelConfigPageProps {
|
||||||
channelName: string
|
channelName: string
|
||||||
|
|
@ -142,6 +142,8 @@ function isConfigured(
|
||||||
)
|
)
|
||||||
case "onebot":
|
case "onebot":
|
||||||
return asString(config.ws_url) !== ""
|
return asString(config.ws_url) !== ""
|
||||||
|
case "weixin":
|
||||||
|
return asString(config.account_id) !== ""
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return asString(config.token) !== ""
|
return asString(config.token) !== ""
|
||||||
case "wecom_app":
|
case "wecom_app":
|
||||||
|
|
@ -238,7 +240,7 @@ 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 gateway = useAtomValue(gatewayAtom)
|
const { state: gatewayState } = useGateway()
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -251,8 +253,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(
|
||||||
setLoading(true)
|
async (silent = false) => {
|
||||||
|
if (!silent) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [catalog, appConfig] = await Promise.all([
|
const [catalog, appConfig] = await Promise.all([
|
||||||
getChannelsCatalog(),
|
getChannelsCatalog(),
|
||||||
|
|
@ -285,22 +288,24 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [channelName, t])
|
},
|
||||||
|
[channelName, t],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
const previousGatewayStatusRef = useRef(gateway.status)
|
const previousGatewayStatusRef = useRef(gatewayState)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousStatus = previousGatewayStatusRef.current
|
const previousStatus = previousGatewayStatusRef.current
|
||||||
if (previousStatus !== "running" && gateway.status === "running") {
|
if (previousStatus !== "running" && gatewayState === "running") {
|
||||||
void loadData()
|
void loadData()
|
||||||
}
|
}
|
||||||
previousGatewayStatusRef.current = gateway.status
|
previousGatewayStatusRef.current = gatewayState
|
||||||
}, [gateway.status, loadData])
|
}, [gatewayState, loadData])
|
||||||
|
|
||||||
const savePayload = useMemo(() => {
|
const savePayload = useMemo(() => {
|
||||||
if (!channel) return null
|
if (!channel) return null
|
||||||
|
|
@ -393,18 +398,28 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
[channel.config_key]: savePayload,
|
[channel.config_key]: savePayload,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
toast.success(t("channels.page.saveSuccess"))
|
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message =
|
const message =
|
||||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||||
setServerError(message)
|
setServerError(message)
|
||||||
toast.error(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleWeixinBindSuccess = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setEnabled(true)
|
||||||
|
await Promise.all([loadData(true), refreshGatewayState({ force: true })])
|
||||||
|
} catch (e) {
|
||||||
|
const message =
|
||||||
|
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||||
|
setServerError(message)
|
||||||
|
await loadData(true)
|
||||||
|
}
|
||||||
|
}, [loadData, t])
|
||||||
|
|
||||||
const renderForm = () => {
|
const renderForm = () => {
|
||||||
if (!channel) return null
|
if (!channel) return null
|
||||||
const isEdit = configured
|
const isEdit = configured
|
||||||
|
|
@ -446,6 +461,15 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case "weixin":
|
||||||
|
return (
|
||||||
|
<WeixinForm
|
||||||
|
config={editConfig}
|
||||||
|
onChange={handleChange}
|
||||||
|
isEdit={isEdit}
|
||||||
|
onBindSuccess={() => void handleWeixinBindSuccess()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<GenericForm
|
<GenericForm
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,351 @@
|
||||||
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconLoader2,
|
||||||
|
IconQrcode,
|
||||||
|
IconRefresh,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
||||||
|
import { Field } from "@/components/shared-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
type BindingState =
|
||||||
|
| "idle"
|
||||||
|
| "loading"
|
||||||
|
| "waiting"
|
||||||
|
| "scaned"
|
||||||
|
| "confirmed"
|
||||||
|
| "expired"
|
||||||
|
| "error"
|
||||||
|
|
||||||
|
interface WeixinFormProps {
|
||||||
|
config: ChannelConfig
|
||||||
|
onChange: (key: string, value: unknown) => void
|
||||||
|
isEdit: boolean
|
||||||
|
onBindSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((item): item is string => typeof item === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeixinForm({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
isEdit,
|
||||||
|
onBindSuccess,
|
||||||
|
}: WeixinFormProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [bindState, setBindState] = useState<BindingState>("idle")
|
||||||
|
const [qrDataURI, setQrDataURI] = useState<string | null>(null)
|
||||||
|
const [accountID, setAccountID] = useState<string | null>(null)
|
||||||
|
const [errorMsg, setErrorMsg] = useState("")
|
||||||
|
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const pollGenerationRef = useRef(0)
|
||||||
|
const isBound = isEdit && asString(config.account_id) !== ""
|
||||||
|
const existingAccountID = asString(config.account_id)
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
pollGenerationRef.current += 1
|
||||||
|
if (pollTimerRef.current !== null) {
|
||||||
|
clearInterval(pollTimerRef.current)
|
||||||
|
pollTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => () => stopPolling(), [stopPolling])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!existingAccountID) return
|
||||||
|
stopPolling()
|
||||||
|
setAccountID(existingAccountID)
|
||||||
|
setBindState("confirmed")
|
||||||
|
setErrorMsg("")
|
||||||
|
}, [existingAccountID, stopPolling])
|
||||||
|
|
||||||
|
const startPolling = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
stopPolling()
|
||||||
|
const generation = pollGenerationRef.current
|
||||||
|
let inFlight = false
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
if (inFlight) return
|
||||||
|
inFlight = true
|
||||||
|
try {
|
||||||
|
const resp = await pollWeixinFlow(id)
|
||||||
|
if (generation !== pollGenerationRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (resp.status === "scaned") {
|
||||||
|
setBindState("scaned")
|
||||||
|
} else if (resp.status === "confirmed") {
|
||||||
|
stopPolling()
|
||||||
|
setAccountID(resp.account_id ?? existingAccountID ?? null)
|
||||||
|
setBindState("confirmed")
|
||||||
|
onBindSuccess?.()
|
||||||
|
} else if (resp.status === "expired") {
|
||||||
|
stopPolling()
|
||||||
|
setBindState("expired")
|
||||||
|
} else if (resp.status === "error") {
|
||||||
|
stopPolling()
|
||||||
|
setBindState("error")
|
||||||
|
setErrorMsg(resp.error ?? t("channels.weixin.errorGeneric"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient network error — keep polling
|
||||||
|
} finally {
|
||||||
|
inFlight = false
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
},
|
||||||
|
[existingAccountID, stopPolling, onBindSuccess, t],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBind = async () => {
|
||||||
|
setBindState("loading")
|
||||||
|
setErrorMsg("")
|
||||||
|
setQrDataURI(null)
|
||||||
|
stopPolling()
|
||||||
|
try {
|
||||||
|
const resp = await startWeixinFlow()
|
||||||
|
setQrDataURI(resp.qr_data_uri ?? null)
|
||||||
|
setBindState("waiting")
|
||||||
|
startPolling(resp.flow_id)
|
||||||
|
} catch (e) {
|
||||||
|
setBindState("error")
|
||||||
|
setErrorMsg(
|
||||||
|
e instanceof Error ? e.message : t("channels.weixin.errorGeneric"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRebind = () => {
|
||||||
|
stopPolling()
|
||||||
|
setBindState("idle")
|
||||||
|
setQrDataURI(null)
|
||||||
|
setAccountID(null)
|
||||||
|
setErrorMsg("")
|
||||||
|
void handleBind()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBindSection = () => {
|
||||||
|
if (bindState === "idle") {
|
||||||
|
if (isBound) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
<IconCheck size={16} />
|
||||||
|
{t("channels.weixin.bound")}
|
||||||
|
</div>
|
||||||
|
{existingAccountID && (
|
||||||
|
<p className="text-muted-foreground font-mono text-xs">
|
||||||
|
{existingAccountID}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRebind}
|
||||||
|
className="mt-1 gap-2"
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} />
|
||||||
|
{t("channels.weixin.rebind")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("channels.weixin.notBound")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleBind} className="gap-2">
|
||||||
|
<IconQrcode size={16} />
|
||||||
|
{t("channels.weixin.bind")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindState === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8">
|
||||||
|
<IconLoader2
|
||||||
|
className="text-muted-foreground animate-spin"
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("channels.weixin.generating")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindState === "waiting" || bindState === "scaned") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
{qrDataURI ? (
|
||||||
|
<img
|
||||||
|
src={qrDataURI}
|
||||||
|
alt="WeChat QR Code"
|
||||||
|
className="border-border/60 h-48 w-48 rounded-xl border bg-white p-2 shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="border-border/60 bg-muted flex h-48 w-48 items-center justify-center rounded-xl border">
|
||||||
|
<IconLoader2
|
||||||
|
className="text-muted-foreground animate-spin"
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bindState === "scaned" ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
<IconLoader2 size={14} className="animate-spin" />
|
||||||
|
{t("channels.weixin.scanned")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("channels.weixin.scanHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRebind}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} className="mr-1" />
|
||||||
|
{t("channels.weixin.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindState === "confirmed") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
|
||||||
|
<IconCheck
|
||||||
|
size={28}
|
||||||
|
className="text-emerald-600 dark:text-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
{t("channels.weixin.bound")}
|
||||||
|
</p>
|
||||||
|
{accountID && (
|
||||||
|
<p className="text-muted-foreground font-mono text-xs">
|
||||||
|
{accountID}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRebind}
|
||||||
|
className="mt-1 gap-2"
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} />
|
||||||
|
{t("channels.weixin.rebind")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindState === "expired") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
|
||||||
|
<IconX size={28} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
{t("channels.weixin.expired")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleRebind} className="gap-2">
|
||||||
|
<IconRefresh size={14} />
|
||||||
|
{t("channels.weixin.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindState === "error") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
|
<div className="bg-destructive/10 flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
|
<IconX size={28} className="text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{errorMsg || t("channels.weixin.errorGeneric")}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={handleRebind} className="gap-2">
|
||||||
|
<IconRefresh size={14} />
|
||||||
|
{t("channels.weixin.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* QR Bind Section */}
|
||||||
|
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
||||||
|
<div className="border-border/60 border-b px-4 py-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("channels.weixin.bindTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{t("channels.weixin.bindDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{renderBindSection()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* allow_from */}
|
||||||
|
<Field
|
||||||
|
label={t("channels.field.allowFrom")}
|
||||||
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={asStringArray(config.allow_from).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
"allow_from",
|
||||||
|
e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s: string) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* proxy */}
|
||||||
|
<Field
|
||||||
|
label={t("channels.field.proxy")}
|
||||||
|
hint={t("channels.form.desc.proxy")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={asString(config.proxy)}
|
||||||
|
onChange={(e) => onChange("proxy", e.target.value)}
|
||||||
|
placeholder="http://localhost:7890"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ interface UserMessageProps {
|
||||||
export function UserMessage({ content }: UserMessageProps) {
|
export function UserMessage({ content }: UserMessageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-end gap-1.5">
|
<div className="flex w-full flex-col items-end gap-1.5">
|
||||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm whitespace-pre-wrap">
|
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed whitespace-pre-wrap text-white shadow-sm">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,11 @@ export function ConfigPage() {
|
||||||
"Max tool iterations",
|
"Max tool iterations",
|
||||||
{ min: 1 },
|
{ min: 1 },
|
||||||
)
|
)
|
||||||
|
const toolFeedbackMaxArgsLength = parseIntField(
|
||||||
|
form.toolFeedbackMaxArgsLength,
|
||||||
|
"Tool feedback max args length",
|
||||||
|
{ min: 0 },
|
||||||
|
)
|
||||||
const summarizeMessageThreshold = parseIntField(
|
const summarizeMessageThreshold = parseIntField(
|
||||||
form.summarizeMessageThreshold,
|
form.summarizeMessageThreshold,
|
||||||
"Summarize message threshold",
|
"Summarize message threshold",
|
||||||
|
|
@ -203,6 +208,10 @@ export function ConfigPage() {
|
||||||
defaults: {
|
defaults: {
|
||||||
workspace,
|
workspace,
|
||||||
restrict_to_workspace: form.restrictToWorkspace,
|
restrict_to_workspace: form.restrictToWorkspace,
|
||||||
|
tool_feedback: {
|
||||||
|
enabled: form.toolFeedbackEnabled,
|
||||||
|
max_args_length: toolFeedbackMaxArgsLength,
|
||||||
|
},
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
context_window: contextWindow,
|
context_window: contextWindow,
|
||||||
max_tool_iterations: maxToolIterations,
|
max_tool_iterations: maxToolIterations,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,33 @@ export function AgentDefaultsSection({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.tool_feedback_enabled")}
|
||||||
|
hint={t("pages.config.tool_feedback_enabled_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.toolFeedbackEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("toolFeedbackEnabled", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.toolFeedbackEnabled && (
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.tool_feedback_max_args_length")}
|
||||||
|
hint={t("pages.config.tool_feedback_max_args_length_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.toolFeedbackMaxArgsLength}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("toolFeedbackMaxArgsLength", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("pages.config.max_tokens")}
|
label={t("pages.config.max_tokens")}
|
||||||
hint={t("pages.config.max_tokens_hint")}
|
hint={t("pages.config.max_tokens_hint")}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ export type JsonRecord = Record<string, unknown>
|
||||||
export interface CoreConfigForm {
|
export interface CoreConfigForm {
|
||||||
workspace: string
|
workspace: string
|
||||||
restrictToWorkspace: boolean
|
restrictToWorkspace: boolean
|
||||||
|
toolFeedbackEnabled: boolean
|
||||||
|
toolFeedbackMaxArgsLength: string
|
||||||
execEnabled: boolean
|
execEnabled: boolean
|
||||||
allowRemote: boolean
|
allowRemote: boolean
|
||||||
enableDenyPatterns: boolean
|
enableDenyPatterns: boolean
|
||||||
|
|
@ -63,6 +65,8 @@ export const DM_SCOPE_OPTIONS = [
|
||||||
export const EMPTY_FORM: CoreConfigForm = {
|
export const EMPTY_FORM: CoreConfigForm = {
|
||||||
workspace: "",
|
workspace: "",
|
||||||
restrictToWorkspace: true,
|
restrictToWorkspace: true,
|
||||||
|
toolFeedbackEnabled: true,
|
||||||
|
toolFeedbackMaxArgsLength: "300",
|
||||||
execEnabled: true,
|
execEnabled: true,
|
||||||
allowRemote: true,
|
allowRemote: true,
|
||||||
enableDenyPatterns: true,
|
enableDenyPatterns: true,
|
||||||
|
|
@ -124,6 +128,7 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
const tools = asRecord(root.tools)
|
const tools = asRecord(root.tools)
|
||||||
const cron = asRecord(tools.cron)
|
const cron = asRecord(tools.cron)
|
||||||
const exec = asRecord(tools.exec)
|
const exec = asRecord(tools.exec)
|
||||||
|
const toolFeedback = asRecord(defaults.tool_feedback)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
|
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
|
||||||
|
|
@ -131,6 +136,14 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
defaults.restrict_to_workspace === undefined
|
defaults.restrict_to_workspace === undefined
|
||||||
? EMPTY_FORM.restrictToWorkspace
|
? EMPTY_FORM.restrictToWorkspace
|
||||||
: asBool(defaults.restrict_to_workspace),
|
: asBool(defaults.restrict_to_workspace),
|
||||||
|
toolFeedbackEnabled:
|
||||||
|
toolFeedback.enabled === undefined
|
||||||
|
? EMPTY_FORM.toolFeedbackEnabled
|
||||||
|
: asBool(toolFeedback.enabled),
|
||||||
|
toolFeedbackMaxArgsLength: asNumberString(
|
||||||
|
toolFeedback.max_args_length,
|
||||||
|
EMPTY_FORM.toolFeedbackMaxArgsLength,
|
||||||
|
),
|
||||||
execEnabled:
|
execEnabled:
|
||||||
exec.enabled === undefined
|
exec.enabled === undefined
|
||||||
? EMPTY_FORM.execEnabled
|
? EMPTY_FORM.execEnabled
|
||||||
|
|
@ -166,7 +179,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
EMPTY_FORM.cronExecTimeoutMinutes,
|
EMPTY_FORM.cronExecTimeoutMinutes,
|
||||||
),
|
),
|
||||||
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
||||||
contextWindow: asNumberString(defaults.context_window, EMPTY_FORM.contextWindow),
|
contextWindow: asNumberString(
|
||||||
|
defaults.context_window,
|
||||||
|
EMPTY_FORM.contextWindow,
|
||||||
|
),
|
||||||
maxToolIterations: asNumberString(
|
maxToolIterations: asNumberString(
|
||||||
defaults.max_tool_iterations,
|
defaults.max_tool_iterations,
|
||||||
EMPTY_FORM.maxToolIterations,
|
EMPTY_FORM.maxToolIterations,
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,20 @@ export function maskedSecretPlaceholder(value: unknown, fallback = ""): string {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secret.length < 7) {
|
// ensure at least 40% of the characters are masked for secrets of length 4 or more
|
||||||
|
if (secret.length <= 6) {
|
||||||
const first = secret[0]
|
const first = secret[0]
|
||||||
const last = secret[secret.length - 1]
|
const last = secret[secret.length - 1]
|
||||||
return `${first}***${last}`
|
return `${first}***${last}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = secret.slice(0, Math.min(3, secret.length))
|
if (secret.length <= 12) {
|
||||||
const suffix = secret.slice(-Math.min(4, secret.length))
|
const firstTwo = secret.slice(0, 2)
|
||||||
return `${prefix}***${suffix}`
|
const lastTwo = secret.slice(-2)
|
||||||
|
return `${firstTwo}****${lastTwo}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = secret.slice(0, 3)
|
||||||
|
const suffix = secret.slice(-4)
|
||||||
|
return `${prefix}*****${suffix}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,7 @@ import { getChannelDisplayName } from "@/components/channels/channel-display-nam
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
import { gatewayAtom } from "@/store/gateway"
|
||||||
|
|
||||||
const DEFAULT_VISIBLE_CHANNELS = 4
|
const DEFAULT_VISIBLE_CHANNELS = 4
|
||||||
const CHANNEL_IMPORTANCE_ORDER = [
|
const CHANNEL_IMPORTANCE_TAIL = [
|
||||||
"discord",
|
|
||||||
"feishu",
|
|
||||||
"telegram",
|
|
||||||
"slack",
|
"slack",
|
||||||
"line",
|
"line",
|
||||||
"wecom",
|
"wecom",
|
||||||
|
|
@ -47,9 +44,13 @@ const CHANNEL_IMPORTANCE_ORDER = [
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"whatsapp_native",
|
"whatsapp_native",
|
||||||
]
|
]
|
||||||
const CHANNEL_IMPORTANCE_INDEX = new Map(
|
|
||||||
CHANNEL_IMPORTANCE_ORDER.map((name, index) => [name, index]),
|
function getChannelImportanceOrder(language: string): string[] {
|
||||||
)
|
const priority = language.startsWith("zh")
|
||||||
|
? ["feishu", "weixin", "discord", "telegram"]
|
||||||
|
: ["discord", "telegram", "feishu", "weixin"]
|
||||||
|
return [...priority, ...CHANNEL_IMPORTANCE_TAIL]
|
||||||
|
}
|
||||||
|
|
||||||
function IconLark({ className }: { className?: string }) {
|
function IconLark({ className }: { className?: string }) {
|
||||||
return React.createElement("span", {
|
return React.createElement("span", {
|
||||||
|
|
@ -75,6 +76,7 @@ const CHANNEL_ICON_MAP: Record<
|
||||||
dingtalk: IconBrandDingtalk,
|
dingtalk: IconBrandDingtalk,
|
||||||
line: IconBrandLine,
|
line: IconBrandLine,
|
||||||
qq: IconBrandQq,
|
qq: IconBrandQq,
|
||||||
|
weixin: IconBrandWechat,
|
||||||
wecom: IconBrandWechat,
|
wecom: IconBrandWechat,
|
||||||
wecom_app: IconBrandWechat,
|
wecom_app: IconBrandWechat,
|
||||||
wecom_aibot: IconBrandWechat,
|
wecom_aibot: IconBrandWechat,
|
||||||
|
|
@ -134,10 +136,11 @@ export interface SidebarChannelNavItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseSidebarChannelsOptions {
|
interface UseSidebarChannelsOptions {
|
||||||
|
language: string
|
||||||
t: TFunction
|
t: TFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
export function useSidebarChannels({ language, t }: UseSidebarChannelsOptions) {
|
||||||
const gateway = useAtomValue(gatewayAtom)
|
const gateway = useAtomValue(gatewayAtom)
|
||||||
const [channels, setChannels] = React.useState<SupportedChannel[]>([])
|
const [channels, setChannels] = React.useState<SupportedChannel[]>([])
|
||||||
const [enabledMap, setEnabledMap] = React.useState<Record<string, boolean>>(
|
const [enabledMap, setEnabledMap] = React.useState<Record<string, boolean>>(
|
||||||
|
|
@ -183,6 +186,12 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
||||||
previousGatewayStatusRef.current = gateway.status
|
previousGatewayStatusRef.current = gateway.status
|
||||||
}, [gateway.status, reloadChannels])
|
}, [gateway.status, reloadChannels])
|
||||||
|
|
||||||
|
const channelImportanceIndex = React.useMemo(() => {
|
||||||
|
return new Map(
|
||||||
|
getChannelImportanceOrder(language).map((name, index) => [name, index]),
|
||||||
|
)
|
||||||
|
}, [language])
|
||||||
|
|
||||||
const sortedChannels = React.useMemo(() => {
|
const sortedChannels = React.useMemo(() => {
|
||||||
const list = [...channels]
|
const list = [...channels]
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
|
|
@ -193,9 +202,9 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const aImportance =
|
const aImportance =
|
||||||
CHANNEL_IMPORTANCE_INDEX.get(a.name) ?? Number.MAX_SAFE_INTEGER
|
channelImportanceIndex.get(a.name) ?? Number.MAX_SAFE_INTEGER
|
||||||
const bImportance =
|
const bImportance =
|
||||||
CHANNEL_IMPORTANCE_INDEX.get(b.name) ?? Number.MAX_SAFE_INTEGER
|
channelImportanceIndex.get(b.name) ?? Number.MAX_SAFE_INTEGER
|
||||||
if (aImportance !== bImportance) {
|
if (aImportance !== bImportance) {
|
||||||
return aImportance - bImportance
|
return aImportance - bImportance
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +214,7 @@ export function useSidebarChannels({ t }: UseSidebarChannelsOptions) {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
return list
|
return list
|
||||||
}, [channels, enabledMap, t])
|
}, [channelImportanceIndex, channels, enabledMap, t])
|
||||||
|
|
||||||
const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS
|
const hasMoreChannels = sortedChannels.length > DEFAULT_VISIBLE_CHANNELS
|
||||||
const visibleChannels = showAllChannels
|
const visibleChannels = showAllChannels
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,26 @@
|
||||||
"pico": "Web",
|
"pico": "Web",
|
||||||
"maixcam": "MaixCam",
|
"maixcam": "MaixCam",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"irc": "IRC"
|
"irc": "IRC",
|
||||||
|
"weixin": "WeChat"
|
||||||
|
},
|
||||||
|
"weixin": {
|
||||||
|
"warningTitle": "Testing phase, use with caution",
|
||||||
|
"warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.",
|
||||||
|
"bindEnableSuccess": "WeChat connected and the channel has been enabled automatically.",
|
||||||
|
"bindTitle": "WeChat Account Binding",
|
||||||
|
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
||||||
|
"bind": "Bind WeChat",
|
||||||
|
"rebind": "Re-bind",
|
||||||
|
"bound": "WeChat Bound",
|
||||||
|
"notBound": "WeChat account not bound yet.",
|
||||||
|
"generating": "Generating QR code...",
|
||||||
|
"scanHint": "Open WeChat and scan the QR code",
|
||||||
|
"scanned": "Scanned — please confirm in WeChat",
|
||||||
|
"expired": "QR code expired",
|
||||||
|
"retry": "Try Again",
|
||||||
|
"refresh": "Refresh QR",
|
||||||
|
"errorGeneric": "An error occurred. Please try again."
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"token": "Bot Token",
|
"token": "Bot Token",
|
||||||
|
|
@ -273,7 +292,9 @@
|
||||||
"saveError": "Failed to save channel configuration",
|
"saveError": "Failed to save channel configuration",
|
||||||
"enabled": "enabled",
|
"enabled": "enabled",
|
||||||
"docLink": "Documentation",
|
"docLink": "Documentation",
|
||||||
"enableLabel": "Enable channel"
|
"enableLabel": "Enable channel",
|
||||||
|
"restartRequiredTitle": "Gateway restart required",
|
||||||
|
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
|
|
@ -396,6 +417,10 @@
|
||||||
"workspace_hint": "Base directory for agent file operations.",
|
"workspace_hint": "Base directory for agent file operations.",
|
||||||
"restrict_workspace": "Restrict to Workspace",
|
"restrict_workspace": "Restrict to Workspace",
|
||||||
"restrict_workspace_hint": "Only allow file operations inside workspace.",
|
"restrict_workspace_hint": "Only allow file operations inside workspace.",
|
||||||
|
"tool_feedback_enabled": "Tool Feedback",
|
||||||
|
"tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.",
|
||||||
|
"tool_feedback_max_args_length": "Tool Feedback Args Preview Length",
|
||||||
|
"tool_feedback_max_args_length_hint": "Maximum number of argument characters shown in each tool feedback message. Set to 0 to use the default.",
|
||||||
"exec_enabled": "Allow Commands",
|
"exec_enabled": "Allow Commands",
|
||||||
"exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.",
|
"exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.",
|
||||||
"allow_remote": "Allow Remote Commands",
|
"allow_remote": "Allow Remote Commands",
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,26 @@
|
||||||
"pico": "Web",
|
"pico": "Web",
|
||||||
"maixcam": "MaixCam",
|
"maixcam": "MaixCam",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"irc": "IRC"
|
"irc": "IRC",
|
||||||
|
"weixin": "微信"
|
||||||
|
},
|
||||||
|
"weixin": {
|
||||||
|
"warningTitle": "测试阶段,请谨慎使用",
|
||||||
|
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
|
||||||
|
"bindEnableSuccess": "微信已连接,频道已自动启用。",
|
||||||
|
"bindTitle": "微信账号绑定",
|
||||||
|
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||||
|
"bind": "绑定微信",
|
||||||
|
"rebind": "重新绑定",
|
||||||
|
"bound": "微信已绑定",
|
||||||
|
"notBound": "尚未绑定微信账号。",
|
||||||
|
"generating": "正在生成二维码...",
|
||||||
|
"scanHint": "打开微信,扫描二维码",
|
||||||
|
"scanned": "已扫码 — 请在微信中确认",
|
||||||
|
"expired": "二维码已过期",
|
||||||
|
"retry": "重试",
|
||||||
|
"refresh": "刷新二维码",
|
||||||
|
"errorGeneric": "发生错误,请重试。"
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"token": "Bot Token",
|
"token": "Bot Token",
|
||||||
|
|
@ -273,7 +292,9 @@
|
||||||
"saveError": "保存频道配置失败",
|
"saveError": "保存频道配置失败",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"docLink": "配置文档",
|
"docLink": "配置文档",
|
||||||
"enableLabel": "启用频道"
|
"enableLabel": "启用频道",
|
||||||
|
"restartRequiredTitle": "需要重启服务",
|
||||||
|
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
|
|
@ -396,6 +417,10 @@
|
||||||
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
|
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
|
||||||
"restrict_workspace": "限制工作目录访问",
|
"restrict_workspace": "限制工作目录访问",
|
||||||
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
|
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
|
||||||
|
"tool_feedback_enabled": "工具反馈",
|
||||||
|
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览。",
|
||||||
|
"tool_feedback_max_args_length": "工具反馈参数预览长度",
|
||||||
|
"tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值。",
|
||||||
"exec_enabled": "允许命令执行",
|
"exec_enabled": "允许命令执行",
|
||||||
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行。",
|
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行。",
|
||||||
"allow_remote": "允许远程命令执行",
|
"allow_remote": "允许远程命令执行",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue