feat(web): support list editing for channel array fields
Add reusable channel array list controls and parsing utilities for channel forms. Normalize channel string-array payloads in the backend, including pasted values, numeric IDs, hidden characters, duplicates, and empty clears. Also allow FlexibleStringSlice to unmarshal null values and cover the new behavior with backend and config tests.
This commit is contained in:
parent
e556a816e4
commit
a5c7be6c70
15 changed files with 1025 additions and 195 deletions
|
|
@ -22,6 +22,11 @@ import (
|
||||||
type FlexibleStringSlice []string
|
type FlexibleStringSlice []string
|
||||||
|
|
||||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" {
|
||||||
|
*f = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Accept a single JSON string for convenience, e.g.:
|
// Accept a single JSON string for convenience, e.g.:
|
||||||
// "text": "Thinking..."
|
// "text": "Thinking..."
|
||||||
var singleString string
|
var singleString string
|
||||||
|
|
|
||||||
|
|
@ -1258,6 +1258,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) {
|
||||||
input string
|
input string
|
||||||
expected []string
|
expected []string
|
||||||
}{
|
}{
|
||||||
|
{
|
||||||
|
name: "null",
|
||||||
|
input: `null`,
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "single string",
|
name: "single string",
|
||||||
input: `"Thinking..."`,
|
input: `"Thinking..."`,
|
||||||
|
|
@ -1286,6 +1291,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) {
|
||||||
if err := json.Unmarshal([]byte(tt.input), &f); err != nil {
|
if err := json.Unmarshal([]byte(tt.input), &f); err != nil {
|
||||||
t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err)
|
t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err)
|
||||||
}
|
}
|
||||||
|
if tt.expected == nil {
|
||||||
|
if f != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(f) != len(tt.expected) {
|
if len(f) != len(tt.expected) {
|
||||||
t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected))
|
t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
var cfg config.Config
|
var raw map[string]any
|
||||||
if err = json.Unmarshal(body, &cfg); err != nil {
|
if err = json.Unmarshal(body, &raw); 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
|
||||||
}
|
}
|
||||||
var raw map[string]any
|
if err = normalizeChannelArrayFields(raw); err != nil {
|
||||||
if err = json.Unmarshal(body, &raw); err != nil {
|
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalizedBody, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to normalize config payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cfg config.Config
|
||||||
|
if err = json.Unmarshal(normalizedBody, &cfg); 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
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Recursively merge patch into base
|
// Recursively merge patch into base
|
||||||
mergeMap(base, patch)
|
mergeMap(base, patch)
|
||||||
|
if err = normalizeChannelArrayFields(base); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert merged map back to Config struct
|
// Convert merged map back to Config struct
|
||||||
merged, err := json.Marshal(base)
|
merged, err := json.Marshal(base)
|
||||||
|
|
@ -382,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) {
|
||||||
return m, isMap
|
return m, isMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]")
|
||||||
|
allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+")
|
||||||
|
conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+")
|
||||||
|
)
|
||||||
|
|
||||||
|
type stringArrayParserOptions struct {
|
||||||
|
stripHiddenChars bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChannelArrayFields(raw map[string]any) error {
|
||||||
|
channelsMap, hasChannels := asMapField(raw, "channel_list")
|
||||||
|
if !hasChannels {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultCfg := config.DefaultConfig()
|
||||||
|
for channelName, rawChannel := range channelsMap {
|
||||||
|
chMap, ok := rawChannel.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawAllowFrom, exists := chMap["allow_from"]; exists {
|
||||||
|
normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{
|
||||||
|
stripHiddenChars: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err)
|
||||||
|
}
|
||||||
|
chMap["allow_from"] = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok {
|
||||||
|
if rawPrefixes, exists := groupTrigger["prefixes"]; exists {
|
||||||
|
normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err)
|
||||||
|
}
|
||||||
|
groupTrigger["prefixes"] = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsMap, hasSettings := asMapField(chMap, "settings")
|
||||||
|
if !hasSettings {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsType := channelSettingsType(defaultCfg, channelName, chMap)
|
||||||
|
if settingsType == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range settingsType.NumField() {
|
||||||
|
field := settingsType.Field(i)
|
||||||
|
if !field.IsExported() || !isStringSliceType(field.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jsonKey := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||||
|
if jsonKey == "" || jsonKey == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawValue, exists := settingsMap[jsonKey]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
options := stringArrayParserOptions{}
|
||||||
|
if jsonKey == "allow_from" {
|
||||||
|
options.stripHiddenChars = true
|
||||||
|
}
|
||||||
|
normalized, err := normalizeStringArrayValue(rawValue, options)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err)
|
||||||
|
}
|
||||||
|
settingsMap[jsonKey] = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelSettingsType(
|
||||||
|
defaultCfg *config.Config,
|
||||||
|
channelName string,
|
||||||
|
channelMap map[string]any,
|
||||||
|
) reflect.Type {
|
||||||
|
if channelType, _ := channelMap["type"].(string); channelType != "" {
|
||||||
|
if bc := defaultCfg.Channels.GetByType(channelType); bc != nil {
|
||||||
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||||
|
return derefType(reflect.TypeOf(decoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bc := defaultCfg.Channels.Get(channelName); bc != nil {
|
||||||
|
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
|
||||||
|
return derefType(reflect.TypeOf(decoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefType(typ reflect.Type) reflect.Type {
|
||||||
|
for typ != nil && typ.Kind() == reflect.Ptr {
|
||||||
|
typ = typ.Elem()
|
||||||
|
}
|
||||||
|
return typ
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStringSliceType(typ reflect.Type) bool {
|
||||||
|
typ = derefType(typ)
|
||||||
|
return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil, nil
|
||||||
|
case string:
|
||||||
|
return parseStringArrayValue(typed, options), nil
|
||||||
|
case float64:
|
||||||
|
return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil
|
||||||
|
case []string:
|
||||||
|
return normalizeStringArrayItems(typed, options), nil
|
||||||
|
case []any:
|
||||||
|
items := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
switch raw := item.(type) {
|
||||||
|
case string:
|
||||||
|
items = append(items, raw)
|
||||||
|
case float64:
|
||||||
|
items = append(items, fmt.Sprintf("%.0f", raw))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported list item type %T", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeStringArrayItems(items, options), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported list field type %T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStringArrayValue(raw string, options stringArrayParserOptions) []string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
splitRe := conservativeSplitRe
|
||||||
|
if options.stripHiddenChars {
|
||||||
|
splitRe = allowFromSplitRe
|
||||||
|
}
|
||||||
|
return normalizeStringArrayItems(splitRe.Split(raw, -1), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string {
|
||||||
|
result := make([]string, 0, len(items))
|
||||||
|
seen := make(map[string]struct{}, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
normalized := item
|
||||||
|
if options.stripHiddenChars {
|
||||||
|
normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "")
|
||||||
|
}
|
||||||
|
normalized = strings.TrimSpace(normalized)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[normalized]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
result = append(result, normalized)
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func getSecretString(m map[string]any, key string) (string, bool) {
|
func getSecretString(m map[string]any, key string) (string, bool) {
|
||||||
if raw, exists := m[key]; exists {
|
if raw, exists := m[key]; exists {
|
||||||
s, isString := raw.(string)
|
s, isString := raw.(string)
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,285 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channel_list": {
|
||||||
|
"pico": {
|
||||||
|
"type": "pico",
|
||||||
|
"allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ",
|
||||||
|
"group_trigger": {
|
||||||
|
"prefixes": "/,!;\n?,/"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"irc": {
|
||||||
|
"type": "irc",
|
||||||
|
"settings": {
|
||||||
|
"channels": "#ops,\n#dev,\n#ops",
|
||||||
|
"request_caps": "multi-prefix,echo-message\tbatch,multi-prefix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picoChannel := cfg.Channels[config.ChannelPico]
|
||||||
|
if len(picoChannel.AllowFrom) != 3 ||
|
||||||
|
picoChannel.AllowFrom[0] != "ou_a" ||
|
||||||
|
picoChannel.AllowFrom[1] != "ou_b" ||
|
||||||
|
picoChannel.AllowFrom[2] != "ou_c" {
|
||||||
|
t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
if len(picoChannel.GroupTrigger.Prefixes) != 3 ||
|
||||||
|
picoChannel.GroupTrigger.Prefixes[0] != "/" ||
|
||||||
|
picoChannel.GroupTrigger.Prefixes[1] != "!;" ||
|
||||||
|
picoChannel.GroupTrigger.Prefixes[2] != "?" {
|
||||||
|
t.Fatalf(
|
||||||
|
"pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]",
|
||||||
|
picoChannel.GroupTrigger.Prefixes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := picoChannel.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDecoded() pico error = %v", err)
|
||||||
|
}
|
||||||
|
picoCfg := decoded.(*config.PicoSettings)
|
||||||
|
if len(picoCfg.AllowOrigins) != 2 ||
|
||||||
|
picoCfg.AllowOrigins[0] != "https://a.example.com" ||
|
||||||
|
picoCfg.AllowOrigins[1] != "http://localhost:5173" {
|
||||||
|
t.Fatalf(
|
||||||
|
"pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]",
|
||||||
|
picoCfg.AllowOrigins,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ircChannel := cfg.Channels[config.ChannelIRC]
|
||||||
|
decoded, err = ircChannel.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDecoded() irc error = %v", err)
|
||||||
|
}
|
||||||
|
ircCfg := decoded.(*config.IRCSettings)
|
||||||
|
if len(ircCfg.Channels) != 2 ||
|
||||||
|
ircCfg.Channels[0] != "#ops" ||
|
||||||
|
ircCfg.Channels[1] != "#dev" {
|
||||||
|
t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels)
|
||||||
|
}
|
||||||
|
if len(ircCfg.RequestCaps) != 3 ||
|
||||||
|
ircCfg.RequestCaps[0] != "multi-prefix" ||
|
||||||
|
ircCfg.RequestCaps[1] != "echo-message" ||
|
||||||
|
ircCfg.RequestCaps[2] != "batch" {
|
||||||
|
t.Fatalf(
|
||||||
|
"irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]",
|
||||||
|
ircCfg.RequestCaps,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channel_list": {
|
||||||
|
"telegram": {
|
||||||
|
"type": "telegram",
|
||||||
|
"allow_from": 123456
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||||
|
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" {
|
||||||
|
t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||||
|
telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"}
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "object allow_from",
|
||||||
|
body: `{
|
||||||
|
"channel_list": {
|
||||||
|
"telegram": {
|
||||||
|
"type": "telegram",
|
||||||
|
"allow_from": {"id": "bad"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean allow_from",
|
||||||
|
body: `{
|
||||||
|
"channel_list": {
|
||||||
|
"telegram": {
|
||||||
|
"type": "telegram",
|
||||||
|
"allow_from": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "object settings array",
|
||||||
|
body: `{
|
||||||
|
"channel_list": {
|
||||||
|
"irc": {
|
||||||
|
"type": "irc",
|
||||||
|
"settings": {
|
||||||
|
"channels": {"name": "#ops"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf(
|
||||||
|
"PATCH /api/config status = %d, want %d, body=%s",
|
||||||
|
rec.Code,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
rec.Body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegramChannel := cfg.Channels[config.ChannelTelegram]
|
||||||
|
if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" {
|
||||||
|
t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
feishuChannel := cfg.Channels[config.ChannelFeishu]
|
||||||
|
feishuChannel.Enabled = true
|
||||||
|
feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"}
|
||||||
|
decoded, err := feishuChannel.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDecoded() error = %v", err)
|
||||||
|
}
|
||||||
|
feishuCfg := decoded.(*config.FeishuSettings)
|
||||||
|
feishuCfg.AppID = "cli_existing_app"
|
||||||
|
feishuCfg.AppSecret = *config.NewSecureString("existing-secret")
|
||||||
|
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"channel_list": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"allow_from": "",
|
||||||
|
"settings": {
|
||||||
|
"app_id": "cli_existing_app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
feishuChannel = cfg.Channels[config.ChannelFeishu]
|
||||||
|
if len(feishuChannel.AllowFrom) != 0 {
|
||||||
|
t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
configData, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(configPath) error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(configData), `"allow_from": [""]`) {
|
||||||
|
t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
|
func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { IconX } from "@tabler/icons-react"
|
||||||
|
import {
|
||||||
|
type KeyboardEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import {
|
||||||
|
mergeUniqueStringItems,
|
||||||
|
parseConservativeStringListInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
|
import { Field } from "@/components/shared-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
type StringListParser = (raw: string) => string[]
|
||||||
|
export type ArrayFieldFlusher = () => string[] | null
|
||||||
|
|
||||||
|
type RegisterArrayFieldFlusher = (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
|
||||||
|
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return left.every((item, index) => item === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelArrayListFieldProps {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
required?: boolean
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
parser?: StringListParser
|
||||||
|
fieldPath?: string
|
||||||
|
registerFlusher?: RegisterArrayFieldFlusher
|
||||||
|
resetVersion?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelArrayListField({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
parser = parseConservativeStringListInput,
|
||||||
|
fieldPath,
|
||||||
|
registerFlusher,
|
||||||
|
resetVersion,
|
||||||
|
}: ChannelArrayListFieldProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [draft, setDraft] = useState("")
|
||||||
|
const draftRef = useRef("")
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
const localValueRef = useRef(value)
|
||||||
|
const parserRef = useRef(parser)
|
||||||
|
const onChangeRef = useRef(onChange)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
valueRef.current = value
|
||||||
|
localValueRef.current = value
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = ""
|
||||||
|
setDraft("")
|
||||||
|
}, [resetVersion])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
parserRef.current = parser
|
||||||
|
}, [parser])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
const commitDraft = useCallback(() => {
|
||||||
|
const rawDraft = draftRef.current
|
||||||
|
if (rawDraft.trim() === "") {
|
||||||
|
if (!areStringArraysEqual(localValueRef.current, valueRef.current)) {
|
||||||
|
return localValueRef.current
|
||||||
|
}
|
||||||
|
draftRef.current = ""
|
||||||
|
setDraft("")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
draftRef.current = ""
|
||||||
|
setDraft("")
|
||||||
|
const nextItems = parserRef.current(rawDraft)
|
||||||
|
if (nextItems.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems)
|
||||||
|
localValueRef.current = mergedItems
|
||||||
|
onChangeRef.current(mergedItems)
|
||||||
|
return mergedItems
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fieldPath || !registerFlusher) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registerFlusher(fieldPath, commitDraft)
|
||||||
|
return () => registerFlusher(fieldPath, null)
|
||||||
|
}, [commitDraft, fieldPath, registerFlusher])
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key !== "Enter") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
commitDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
const nextValue = value.filter((_, itemIndex) => itemIndex !== index)
|
||||||
|
localValueRef.current = nextValue
|
||||||
|
onChangeRef.current(nextValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label={label} hint={hint} error={error} required={required}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{value.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={`${item}-${index}`}
|
||||||
|
className="bg-muted text-foreground inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<span className="break-all">{item}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors"
|
||||||
|
aria-label={t("channels.field.removeListItem", {
|
||||||
|
value: item,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconX className="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextDraft = event.target.value
|
||||||
|
draftRef.current = nextDraft
|
||||||
|
setDraft(nextDraft)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={commitDraft}
|
||||||
|
disabled={draft.trim() === ""}
|
||||||
|
>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
web/frontend/src/components/channels/channel-array-utils.ts
Normal file
72
web/frontend/src/components/channels/channel-array-utils.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
const ALLOW_FROM_HIDDEN_CHARS_RE =
|
||||||
|
/\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g
|
||||||
|
|
||||||
|
function normalizeStringListItems(
|
||||||
|
items: string[],
|
||||||
|
options: { stripHiddenChars?: boolean } = {},
|
||||||
|
): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const normalized = options.stripHiddenChars
|
||||||
|
? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "")
|
||||||
|
: item
|
||||||
|
const trimmed = normalized.trim()
|
||||||
|
if (trimmed.length === 0 || seen.has(trimmed)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(trimmed)
|
||||||
|
result.push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitStringList(
|
||||||
|
raw: string,
|
||||||
|
separators: RegExp,
|
||||||
|
options: { stripHiddenChars?: boolean } = {},
|
||||||
|
): string[] {
|
||||||
|
if (raw.trim() === "") {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return normalizeStringListItems(raw.split(separators), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return value.filter((item): item is string => typeof item === "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAllowFromInput(raw: string): string[] {
|
||||||
|
return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, {
|
||||||
|
stripHiddenChars: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConservativeStringListInput(raw: string): string[] {
|
||||||
|
return splitStringList(raw, /[,\uFF0C\n\r\t]+/)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAllowFromValues(value: unknown): string[] {
|
||||||
|
return normalizeStringListItems(asStringArray(value), {
|
||||||
|
stripHiddenChars: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeUniqueStringItems(
|
||||||
|
currentItems: string[],
|
||||||
|
nextItems: string[],
|
||||||
|
): string[] {
|
||||||
|
return normalizeStringListItems([...currentItems, ...nextItems])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeStringArrayForSubmit(value: unknown): unknown {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return normalizeStringListItems(asStringArray(value)).join("\n")
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,11 @@ import {
|
||||||
getChannelsCatalog,
|
getChannelsCatalog,
|
||||||
patchAppConfig,
|
patchAppConfig,
|
||||||
} from "@/api/channels"
|
} from "@/api/channels"
|
||||||
|
import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field"
|
||||||
|
import {
|
||||||
|
normalizeAllowFromValues,
|
||||||
|
serializeStringArrayForSubmit,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
import {
|
import {
|
||||||
SECRET_FIELD_MAP,
|
SECRET_FIELD_MAP,
|
||||||
buildEditConfig,
|
buildEditConfig,
|
||||||
|
|
@ -48,6 +53,43 @@ function asBool(value: unknown): boolean {
|
||||||
return value === true
|
return value === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRecordValueByPath(
|
||||||
|
source: Record<string, unknown>,
|
||||||
|
pathSegments: string[],
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const [segment, ...rest] = pathSegments
|
||||||
|
if (!segment) {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
if (rest.length === 0) {
|
||||||
|
return { ...source, [segment]: value }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
[segment]: setRecordValueByPath(asRecord(source[segment]), rest, value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValueByPath(
|
||||||
|
source: ChannelConfig,
|
||||||
|
fieldPath: string,
|
||||||
|
value: unknown,
|
||||||
|
): ChannelConfig {
|
||||||
|
return setRecordValueByPath(source, fieldPath.split("."), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeGroupTriggerForSubmit(value: unknown): unknown {
|
||||||
|
const groupTrigger = asRecord(value)
|
||||||
|
if (Object.keys(groupTrigger).length === 0) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...groupTrigger,
|
||||||
|
prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const CHANNEL_COMMON_CONFIG_KEYS = new Set([
|
const CHANNEL_COMMON_CONFIG_KEYS = new Set([
|
||||||
"allow_from",
|
"allow_from",
|
||||||
"group_trigger",
|
"group_trigger",
|
||||||
|
|
@ -82,12 +124,20 @@ function buildSavePayload(
|
||||||
if (key.startsWith("_")) continue
|
if (key.startsWith("_")) continue
|
||||||
if (key === "enabled") continue
|
if (key === "enabled") continue
|
||||||
if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) {
|
if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) {
|
||||||
|
if (key === "allow_from") {
|
||||||
|
payload[key] = serializeStringArrayForSubmit(
|
||||||
|
normalizeAllowFromValues(value),
|
||||||
|
)
|
||||||
|
} else if (key === "group_trigger") {
|
||||||
|
payload[key] = serializeGroupTriggerForSubmit(value)
|
||||||
|
} else {
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (isSecretField(key)) continue
|
if (isSecretField(key)) continue
|
||||||
|
|
||||||
settings[key] = value
|
settings[key] = serializeStringArrayForSubmit(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
|
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
|
||||||
|
|
@ -244,6 +294,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||||
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
|
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0)
|
||||||
|
const arrayFieldFlushersRef = useRef(new Map<string, ArrayFieldFlusher>())
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
async (silent = false) => {
|
async (silent = false) => {
|
||||||
|
|
@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
previousGatewayStatusRef.current = gatewayState
|
previousGatewayStatusRef.current = gatewayState
|
||||||
}, [gatewayState, loadData])
|
}, [gatewayState, loadData])
|
||||||
|
|
||||||
const savePayload = useMemo(() => {
|
|
||||||
if (!channel) return null
|
|
||||||
return buildSavePayload(channel, editConfig, enabled)
|
|
||||||
}, [channel, editConfig, enabled])
|
|
||||||
|
|
||||||
const configured = useMemo(() => {
|
const configured = useMemo(() => {
|
||||||
if (!channel) return false
|
if (!channel) return false
|
||||||
return isConfigured(channel, editConfig, configuredSecrets)
|
return isConfigured(channel, editConfig, configuredSecrets)
|
||||||
|
|
@ -362,20 +409,52 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const registerArrayFieldFlusher = useCallback(
|
||||||
|
(fieldPath: string, flusher: ArrayFieldFlusher | null) => {
|
||||||
|
if (flusher) {
|
||||||
|
arrayFieldFlushersRef.current.set(fieldPath, flusher)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arrayFieldFlushersRef.current.delete(fieldPath)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const flushPendingArrayFieldDrafts = useCallback(
|
||||||
|
(sourceConfig: ChannelConfig): ChannelConfig => {
|
||||||
|
let nextConfig = sourceConfig
|
||||||
|
for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) {
|
||||||
|
const flushedValue = flusher()
|
||||||
|
if (flushedValue === null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue)
|
||||||
|
}
|
||||||
|
return nextConfig
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (!channel) return
|
if (!channel) return
|
||||||
setEditConfig(buildEditConfig(channel.name, baseConfig))
|
setEditConfig(buildEditConfig(channel.name, baseConfig))
|
||||||
setEnabled(asBool(baseConfig.enabled))
|
setEnabled(asBool(baseConfig.enabled))
|
||||||
setServerError("")
|
setServerError("")
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
|
setArrayFieldResetVersion((version) => version + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!channel || !savePayload) return
|
if (!channel) return
|
||||||
|
|
||||||
|
const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig)
|
||||||
|
if (preparedEditConfig !== editConfig) {
|
||||||
|
setEditConfig(preparedEditConfig)
|
||||||
|
}
|
||||||
|
|
||||||
const missingRequiredFields = requiredKeys.filter((key) =>
|
const missingRequiredFields = requiredKeys.filter((key) =>
|
||||||
isMissingRequiredValue(
|
isMissingRequiredValue(
|
||||||
getFieldValueForValidation(editConfig, configuredSecrets, key),
|
getFieldValueForValidation(preparedEditConfig, configuredSecrets, key),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (missingRequiredFields.length > 0) {
|
if (missingRequiredFields.length > 0) {
|
||||||
|
|
@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
setServerError("")
|
setServerError("")
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
try {
|
try {
|
||||||
|
const savePayload = buildSavePayload(channel, preparedEditConfig, enabled)
|
||||||
await patchAppConfig({
|
await patchAppConfig({
|
||||||
channel_list: {
|
channel_list: {
|
||||||
[channel.config_key]: savePayload,
|
[channel.config_key]: savePayload,
|
||||||
|
|
@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
configuredSecrets={configuredSecrets}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||||
|
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case "discord":
|
case "discord":
|
||||||
|
|
@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
configuredSecrets={configuredSecrets}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||||
|
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case "slack":
|
case "slack":
|
||||||
|
|
@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
configuredSecrets={configuredSecrets}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||||
|
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case "feishu":
|
case "feishu":
|
||||||
|
|
@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
configuredSecrets={configuredSecrets}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||||
|
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case "weixin":
|
case "weixin":
|
||||||
|
|
@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
isEdit={isEdit}
|
||||||
onBindSuccess={() => void handleWeixinBindSuccess()}
|
onBindSuccess={() => void handleWeixinBindSuccess()}
|
||||||
|
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||||
|
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
|
|
@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
||||||
requiredKeys={requiredKeys}
|
requiredKeys={requiredKeys}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||||
|
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
hiddenKeys={hiddenKeys}
|
hiddenKeys={hiddenKeys}
|
||||||
requiredKeys={requiredKeys}
|
requiredKeys={requiredKeys}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
registerArrayFieldFlusher={registerArrayFieldFlusher}
|
||||||
|
arrayFieldResetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
type ArrayFieldFlusher,
|
||||||
|
ChannelArrayListField,
|
||||||
|
} from "@/components/channels/channel-array-list-field"
|
||||||
|
import {
|
||||||
|
asStringArray,
|
||||||
|
parseAllowFromInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
|
@ -11,17 +19,17 @@ interface DiscordFormProps {
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
configuredSecrets: string[]
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
|
registerArrayFieldFlusher?: (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
arrayFieldResetVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string {
|
||||||
return typeof value === "string" ? value : ""
|
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
function asBool(value: unknown): boolean {
|
function asBool(value: unknown): boolean {
|
||||||
return value === true
|
return value === true
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +46,8 @@ export function DiscordForm({
|
||||||
onChange,
|
onChange,
|
||||||
configuredSecrets,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
registerArrayFieldFlusher,
|
||||||
|
arrayFieldResetVersion,
|
||||||
}: DiscordFormProps) {
|
}: DiscordFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||||
|
|
@ -78,24 +88,17 @@ export function DiscordForm({
|
||||||
placeholder="http://127.0.0.1:7890"
|
placeholder="http://127.0.0.1:7890"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
>
|
value={asStringArray(config.allow_from)}
|
||||||
<Input
|
onChange={(value) => onChange("allow_from", value)}
|
||||||
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")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
parser={parseAllowFromInput}
|
||||||
|
fieldPath="allow_from"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
type ArrayFieldFlusher,
|
||||||
|
ChannelArrayListField,
|
||||||
|
} from "@/components/channels/channel-array-list-field"
|
||||||
|
import {
|
||||||
|
asStringArray,
|
||||||
|
parseAllowFromInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
|
@ -11,6 +19,11 @@ interface FeishuFormProps {
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
configuredSecrets: string[]
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
|
registerArrayFieldFlusher?: (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
arrayFieldResetVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string {
|
||||||
|
|
@ -21,16 +34,13 @@ function asBool(value: unknown): boolean {
|
||||||
return typeof value === "boolean" ? value : false
|
return typeof value === "boolean" ? value : false
|
||||||
}
|
}
|
||||||
|
|
||||||
function asStringArray(value: unknown): string[] {
|
|
||||||
if (!Array.isArray(value)) return []
|
|
||||||
return value.filter((item): item is string => typeof item === "string")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeishuForm({
|
export function FeishuForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
configuredSecrets,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
registerArrayFieldFlusher,
|
||||||
|
arrayFieldResetVersion,
|
||||||
}: FeishuFormProps) {
|
}: FeishuFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
|
@ -104,24 +114,17 @@ export function FeishuForm({
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
>
|
value={asStringArray(config.allow_from)}
|
||||||
<Input
|
onChange={(value) => onChange("allow_from", value)}
|
||||||
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")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
parser={parseAllowFromInput}
|
||||||
|
fieldPath="allow_from"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
type ArrayFieldFlusher,
|
||||||
|
ChannelArrayListField,
|
||||||
|
} from "@/components/channels/channel-array-list-field"
|
||||||
|
import {
|
||||||
|
asStringArray,
|
||||||
|
parseAllowFromInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
import {
|
import {
|
||||||
getSecretInputPlaceholder,
|
getSecretInputPlaceholder,
|
||||||
isSecretField,
|
isSecretField,
|
||||||
|
|
@ -16,6 +24,11 @@ interface GenericFormProps {
|
||||||
hiddenKeys?: string[]
|
hiddenKeys?: string[]
|
||||||
requiredKeys?: string[]
|
requiredKeys?: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
|
registerArrayFieldFlusher?: (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
arrayFieldResetVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||||
|
|
@ -48,11 +61,6 @@ function asString(value: unknown): string {
|
||||||
return typeof value === "string" ? value : ""
|
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
|
|
@ -71,6 +79,8 @@ export function GenericForm({
|
||||||
hiddenKeys = [],
|
hiddenKeys = [],
|
||||||
requiredKeys = [],
|
requiredKeys = [],
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
registerArrayFieldFlusher,
|
||||||
|
arrayFieldResetVersion,
|
||||||
}: GenericFormProps) {
|
}: GenericFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const hiddenFieldSet = new Set(hiddenKeys)
|
const hiddenFieldSet = new Set(hiddenKeys)
|
||||||
|
|
@ -187,26 +197,18 @@ export function GenericForm({
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return (
|
return (
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
key={key}
|
key={key}
|
||||||
label={formatLabel(key)}
|
label={formatLabel(key)}
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
hint={buildHint(key)}
|
hint={buildHint(key)}
|
||||||
error={fieldErrors[key]}
|
error={fieldErrors[key]}
|
||||||
>
|
value={asStringArray(value)}
|
||||||
<Input
|
onChange={(nextValue) => onChange(key, nextValue)}
|
||||||
value={asStringArray(value).join(", ")}
|
fieldPath={key}
|
||||||
onChange={(e) =>
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
onChange(
|
resetVersion={arrayFieldResetVersion}
|
||||||
key,
|
|
||||||
e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((s: string) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,46 +283,31 @@ export function GenericForm({
|
||||||
|
|
||||||
{config.allow_from !== undefined &&
|
{config.allow_from !== undefined &&
|
||||||
!hiddenFieldSet.has("allow_from") && (
|
!hiddenFieldSet.has("allow_from") && (
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
>
|
value={asStringArray(config.allow_from)}
|
||||||
<Input
|
onChange={(value) => onChange("allow_from", value)}
|
||||||
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")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
parser={parseAllowFromInput}
|
||||||
|
fieldPath="allow_from"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config.allow_origins !== undefined &&
|
{config.allow_origins !== undefined &&
|
||||||
!hiddenFieldSet.has("allow_origins") && (
|
!hiddenFieldSet.has("allow_origins") && (
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.allowOrigins")}
|
label={t("channels.field.allowOrigins")}
|
||||||
hint={t("channels.form.desc.allowOrigins")}
|
hint={t("channels.form.desc.allowOrigins")}
|
||||||
>
|
value={asStringArray(config.allow_origins)}
|
||||||
<Input
|
onChange={(value) => onChange("allow_origins", value)}
|
||||||
value={asStringArray(config.allow_origins).join(", ")}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
"allow_origins",
|
|
||||||
e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((s: string) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||||
|
fieldPath="allow_origins"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config.allow_token_query !== undefined &&
|
{config.allow_token_query !== undefined &&
|
||||||
|
|
@ -356,26 +343,21 @@ export function GenericForm({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.groupTriggerPrefixes")}
|
label={t("channels.field.groupTriggerPrefixes")}
|
||||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||||
>
|
value={asStringArray(groupTriggerConfig.prefixes)}
|
||||||
<Input
|
onChange={(value) =>
|
||||||
value={asStringArray(groupTriggerConfig.prefixes).join(
|
|
||||||
", ",
|
|
||||||
)}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange("group_trigger", {
|
onChange("group_trigger", {
|
||||||
...groupTriggerConfig,
|
...groupTriggerConfig,
|
||||||
prefixes: e.target.value
|
prefixes: value,
|
||||||
.split(",")
|
|
||||||
.map((s: string) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||||
|
fieldPath="group_trigger.prefixes"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,41 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
type ArrayFieldFlusher,
|
||||||
|
ChannelArrayListField,
|
||||||
|
} from "@/components/channels/channel-array-list-field"
|
||||||
|
import {
|
||||||
|
asStringArray,
|
||||||
|
parseAllowFromInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput } from "@/components/shared-form"
|
import { Field, KeyInput } from "@/components/shared-form"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
|
|
||||||
interface SlackFormProps {
|
interface SlackFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
configuredSecrets: string[]
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
|
registerArrayFieldFlusher?: (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
arrayFieldResetVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string {
|
||||||
return typeof value === "string" ? value : ""
|
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 SlackForm({
|
export function SlackForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
configuredSecrets,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
registerArrayFieldFlusher,
|
||||||
|
arrayFieldResetVersion,
|
||||||
}: SlackFormProps) {
|
}: SlackFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
|
@ -72,24 +81,17 @@ export function SlackForm({
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
>
|
value={asStringArray(config.allow_from)}
|
||||||
<Input
|
onChange={(value) => onChange("allow_from", value)}
|
||||||
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")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
parser={parseAllowFromInput}
|
||||||
|
fieldPath="allow_from"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
type ArrayFieldFlusher,
|
||||||
|
ChannelArrayListField,
|
||||||
|
} from "@/components/channels/channel-array-list-field"
|
||||||
|
import {
|
||||||
|
asStringArray,
|
||||||
|
parseAllowFromInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
|
@ -11,17 +19,17 @@ interface TelegramFormProps {
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
configuredSecrets: string[]
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
|
registerArrayFieldFlusher?: (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
arrayFieldResetVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string {
|
||||||
return typeof value === "string" ? value : ""
|
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
|
|
@ -38,6 +46,8 @@ export function TelegramForm({
|
||||||
onChange,
|
onChange,
|
||||||
configuredSecrets,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
registerArrayFieldFlusher,
|
||||||
|
arrayFieldResetVersion,
|
||||||
}: TelegramFormProps) {
|
}: TelegramFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const typingConfig = asRecord(config.typing)
|
const typingConfig = asRecord(config.typing)
|
||||||
|
|
@ -91,24 +101,17 @@ export function TelegramForm({
|
||||||
placeholder="http://127.0.0.1:7890"
|
placeholder="http://127.0.0.1:7890"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
>
|
value={asStringArray(config.allow_from)}
|
||||||
<Input
|
onChange={(value) => onChange("allow_from", value)}
|
||||||
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")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
parser={parseAllowFromInput}
|
||||||
|
fieldPath="allow_from"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
||||||
|
import {
|
||||||
|
type ArrayFieldFlusher,
|
||||||
|
ChannelArrayListField,
|
||||||
|
} from "@/components/channels/channel-array-list-field"
|
||||||
|
import {
|
||||||
|
asStringArray,
|
||||||
|
parseAllowFromInput,
|
||||||
|
} from "@/components/channels/channel-array-utils"
|
||||||
import { Field } from "@/components/shared-form"
|
import { Field } from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,22 +43,24 @@ interface WeixinFormProps {
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
isEdit: boolean
|
||||||
onBindSuccess?: () => void
|
onBindSuccess?: () => void
|
||||||
|
registerArrayFieldFlusher?: (
|
||||||
|
fieldPath: string,
|
||||||
|
flusher: ArrayFieldFlusher | null,
|
||||||
|
) => void
|
||||||
|
arrayFieldResetVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string {
|
||||||
return typeof value === "string" ? value : ""
|
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({
|
export function WeixinForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
isEdit,
|
||||||
onBindSuccess,
|
onBindSuccess,
|
||||||
|
registerArrayFieldFlusher,
|
||||||
|
arrayFieldResetVersion,
|
||||||
}: WeixinFormProps) {
|
}: WeixinFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
|
@ -321,24 +331,17 @@ export function WeixinForm({
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
<Field
|
<ChannelArrayListField
|
||||||
label={t("channels.field.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
>
|
value={asStringArray(config.allow_from)}
|
||||||
<Input
|
onChange={(value) => onChange("allow_from", value)}
|
||||||
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")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
parser={parseAllowFromInput}
|
||||||
|
fieldPath="allow_from"
|
||||||
|
registerFlusher={registerArrayFieldFlusher}
|
||||||
|
resetVersion={arrayFieldResetVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.proxy")}
|
label={t("channels.field.proxy")}
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@
|
||||||
"allowFromPlaceholder": "e.g. 123456, 789012",
|
"allowFromPlaceholder": "e.g. 123456, 789012",
|
||||||
"allowOrigins": "Allow Origins",
|
"allowOrigins": "Allow Origins",
|
||||||
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
|
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
|
||||||
|
"removeListItem": "Remove {{value}}",
|
||||||
"secretPlaceholder": "Enter secret",
|
"secretPlaceholder": "Enter secret",
|
||||||
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
|
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
|
||||||
},
|
},
|
||||||
|
|
@ -386,10 +387,10 @@
|
||||||
"typingEnabled": "Display typing status while the assistant is generating a response.",
|
"typingEnabled": "Display typing status while the assistant is generating a response.",
|
||||||
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
|
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
|
||||||
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
|
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
|
||||||
"groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.",
|
"groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.",
|
||||||
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
|
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
|
||||||
"allowFrom": "Allowed user or group IDs, separated by commas.",
|
"allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.",
|
||||||
"allowOrigins": "Allowed origin domains, separated by commas.",
|
"allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.",
|
||||||
"wsUrl": "WebSocket service URL.",
|
"wsUrl": "WebSocket service URL.",
|
||||||
"reconnectInterval": "Reconnect interval after disconnection (seconds).",
|
"reconnectInterval": "Reconnect interval after disconnection (seconds).",
|
||||||
"bridgeUrl": "Bridge service URL.",
|
"bridgeUrl": "Bridge service URL.",
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@
|
||||||
"allowFromPlaceholder": "例如 123456, 789012",
|
"allowFromPlaceholder": "例如 123456, 789012",
|
||||||
"allowOrigins": "允许来源域名",
|
"allowOrigins": "允许来源域名",
|
||||||
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
||||||
|
"removeListItem": "删除 {{value}}",
|
||||||
"secretPlaceholder": "输入密钥",
|
"secretPlaceholder": "输入密钥",
|
||||||
"secretHintSet": "配置已保存,留空表示不修改"
|
"secretHintSet": "配置已保存,留空表示不修改"
|
||||||
},
|
},
|
||||||
|
|
@ -386,10 +387,10 @@
|
||||||
"typingEnabled": "在生成回复时显示“正在输入”状态",
|
"typingEnabled": "在生成回复时显示“正在输入”状态",
|
||||||
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
|
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
|
||||||
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
|
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
|
||||||
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
|
"groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。",
|
||||||
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)",
|
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)",
|
||||||
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔",
|
"allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。",
|
||||||
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔",
|
"allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。",
|
||||||
"wsUrl": "WebSocket 服务地址",
|
"wsUrl": "WebSocket 服务地址",
|
||||||
"reconnectInterval": "断线后的重连间隔(秒)",
|
"reconnectInterval": "断线后的重连间隔(秒)",
|
||||||
"bridgeUrl": "桥接服务地址",
|
"bridgeUrl": "桥接服务地址",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue