Merge branch 'sipeed:main' into main
This commit is contained in:
commit
2a51fc7231
18 changed files with 1400 additions and 37 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -40,6 +40,7 @@ tasks/
|
||||||
|
|
||||||
# Plans
|
# Plans
|
||||||
docs/plans/
|
docs/plans/
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -815,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"`
|
||||||
|
|
@ -2019,6 +2020,12 @@ func (c *Config) SecurityCopyFrom(cfg *Config) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
var all []string
|
var all []string
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -152,9 +152,13 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy security credentials before validation so security-managed
|
// Restore security fields (tokens/keys) from the loaded config before validation,
|
||||||
// fields (e.g. pico token) are available for validation checks.
|
// because private fields are lost during JSON round-trip.
|
||||||
newCfg.SecurityCopyFrom(cfg)
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -18,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)
|
||||||
|
|
@ -43,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)
|
||||||
|
|
@ -61,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)
|
||||||
|
|
@ -81,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)
|
||||||
|
|
@ -102,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)
|
||||||
|
|
@ -131,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)
|
||||||
|
|
@ -169,12 +169,12 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(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.Fatal("ensurePicoChannel() should report changed when pico is missing")
|
t.Fatal("EnsurePicoChannel() should report changed when pico is missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err = config.LoadConfig(configPath)
|
cfg, err = config.LoadConfig(configPath)
|
||||||
|
|
@ -193,6 +193,33 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -200,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,15 +17,18 @@ 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.
|
||||||
func NewHandler(configPath string) *Handler {
|
func NewHandler(configPath string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
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.
|
||||||
|
|
|
||||||
300
web/backend/api/weixin.go
Normal file
300
web/backend/api/weixin.go
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
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.saveWeixinToken(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveWeixinToken writes the token and account ID into the config file.
|
||||||
|
func (h *Handler) saveWeixinToken(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)
|
||||||
|
if accountID != "" {
|
||||||
|
cfg.Channels.Weixin.AccountID = accountID
|
||||||
|
}
|
||||||
|
return config.SaveConfig(h.configPath, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,22 @@ 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 }
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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"
|
||||||
|
|
@ -142,6 +143,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":
|
||||||
|
|
@ -251,8 +254,8 @@ 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(async (silent = false) => {
|
||||||
setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [catalog, appConfig] = await Promise.all([
|
const [catalog, appConfig] = await Promise.all([
|
||||||
getChannelsCatalog(),
|
getChannelsCatalog(),
|
||||||
|
|
@ -285,7 +288,7 @@ 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])
|
||||||
|
|
||||||
|
|
@ -446,6 +449,15 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case "weixin":
|
||||||
|
return (
|
||||||
|
<WeixinForm
|
||||||
|
config={editConfig}
|
||||||
|
onChange={handleChange}
|
||||||
|
isEdit={isEdit}
|
||||||
|
onBindSuccess={() => void loadData(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<GenericForm
|
<GenericForm
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { IconLoader2, IconRefresh, IconCheck, IconX, IconQrcode } 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 isBound = isEdit && asString(config.account_id) !== ""
|
||||||
|
const existingAccountID = asString(config.account_id)
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollTimerRef.current !== null) {
|
||||||
|
clearInterval(pollTimerRef.current)
|
||||||
|
pollTimerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => () => stopPolling(), [stopPolling])
|
||||||
|
|
||||||
|
const startPolling = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
stopPolling()
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await pollWeixinFlow(id)
|
||||||
|
if (resp.status === "scaned") {
|
||||||
|
setBindState("scaned")
|
||||||
|
} else if (resp.status === "confirmed") {
|
||||||
|
stopPolling()
|
||||||
|
setAccountID(resp.account_id ?? 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
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
},
|
||||||
|
[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-xs text-muted-foreground font-mono">{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-sm text-muted-foreground">{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="animate-spin text-muted-foreground" size={32} />
|
||||||
|
<p className="text-sm text-muted-foreground">{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="h-48 w-48 rounded-xl border border-border/60 bg-white p-2 shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-48 w-48 items-center justify-center rounded-xl border border-border/60 bg-muted">
|
||||||
|
<IconLoader2 className="animate-spin text-muted-foreground" 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-sm text-muted-foreground">{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-xs text-muted-foreground font-mono">{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="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<IconX size={28} className="text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-destructive">{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="rounded-xl border border-border/60 bg-muted/30">
|
||||||
|
<div className="border-b border-border/60 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium">{t("channels.weixin.bindTitle")}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -240,7 +240,23 @@
|
||||||
"pico": "Web",
|
"pico": "Web",
|
||||||
"maixcam": "MaixCam",
|
"maixcam": "MaixCam",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"irc": "IRC"
|
"irc": "IRC",
|
||||||
|
"weixin": "WeChat"
|
||||||
|
},
|
||||||
|
"weixin": {
|
||||||
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,23 @@
|
||||||
"pico": "Web",
|
"pico": "Web",
|
||||||
"maixcam": "MaixCam",
|
"maixcam": "MaixCam",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"irc": "IRC"
|
"irc": "IRC",
|
||||||
|
"weixin": "微信"
|
||||||
|
},
|
||||||
|
"weixin": {
|
||||||
|
"bindTitle": "微信账号绑定",
|
||||||
|
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||||
|
"bind": "绑定微信",
|
||||||
|
"rebind": "重新绑定",
|
||||||
|
"bound": "微信已绑定",
|
||||||
|
"notBound": "尚未绑定微信账号。",
|
||||||
|
"generating": "正在生成二维码...",
|
||||||
|
"scanHint": "打开微信,扫描二维码",
|
||||||
|
"scanned": "已扫码 — 请在微信中确认",
|
||||||
|
"expired": "二维码已过期",
|
||||||
|
"retry": "重试",
|
||||||
|
"refresh": "刷新二维码",
|
||||||
|
"errorGeneric": "发生错误,请重试。"
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"token": "Bot Token",
|
"token": "Bot Token",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue