From ba6992234faaef77e55abb5c3ab3cf5fe5193656 Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 21 Apr 2026 16:04:28 +0800 Subject: [PATCH] feat(web): support list editing for channel array fields (#2595) 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. --- pkg/config/config_struct.go | 5 + pkg/config/config_test.go | 11 + web/backend/api/config.go | 199 ++++++++++++- web/backend/api/config_test.go | 279 ++++++++++++++++++ .../channels/channel-array-list-field.tsx | 180 +++++++++++ .../channels/channel-array-utils.ts | 72 +++++ .../channels/channel-config-page.tsx | 112 ++++++- .../channels/channel-forms/discord-form.tsx | 45 +-- .../channels/channel-forms/feishu-form.tsx | 45 +-- .../channels/channel-forms/generic-form.tsx | 122 ++++---- .../channels/channel-forms/slack-form.tsx | 46 +-- .../channels/channel-forms/telegram-form.tsx | 45 +-- .../channels/channel-forms/weixin-form.tsx | 45 +-- web/frontend/src/i18n/locales/en.json | 7 +- web/frontend/src/i18n/locales/zh.json | 7 +- 15 files changed, 1025 insertions(+), 195 deletions(-) create mode 100644 web/frontend/src/components/channels/channel-array-list-field.tsx create mode 100644 web/frontend/src/components/channels/channel-array-utils.ts diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go index 6eaf32bc1..65cfeb107 100644 --- a/pkg/config/config_struct.go +++ b/pkg/config/config_struct.go @@ -22,6 +22,11 @@ import ( type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + // Accept a single JSON string for convenience, e.g.: // "text": "Thinking..." var singleString string diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d9ca0cb9d..24c86d452 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1258,6 +1258,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { input string expected []string }{ + { + name: "null", + input: `null`, + expected: nil, + }, { name: "single string", input: `"Thinking..."`, @@ -1286,6 +1291,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { if err := json.Unmarshal([]byte(tt.input), &f); err != nil { 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) { t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected)) } diff --git a/web/backend/api/config.go b/web/backend/api/config.go index c7bd21197..afcd3f74e 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var cfg config.Config - if err = json.Unmarshal(body, &cfg); err != nil { + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } - var raw map[string]any - if err = json.Unmarshal(body, &raw); err != nil { + if err = normalizeChannelArrayFields(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) return } @@ -154,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { // Recursively merge patch into base 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 merged, err := json.Marshal(base) @@ -382,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) { 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) { if raw, exists := m[key]; exists { s, isString := raw.(string) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 0e0fa5229..8377c2eca 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -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) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/frontend/src/components/channels/channel-array-list-field.tsx b/web/frontend/src/components/channels/channel-array-list-field.tsx new file mode 100644 index 000000000..ff601c07b --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-list-field.tsx @@ -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) => { + 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 ( + +
+ {value.length > 0 && ( +
+ {value.map((item, index) => ( + + {item} + + + ))} +
+ )} + +
+ { + const nextDraft = event.target.value + draftRef.current = nextDraft + setDraft(nextDraft) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> + +
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-array-utils.ts b/web/frontend/src/components/channels/channel-array-utils.ts new file mode 100644 index 000000000..0f6268be8 --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-utils.ts @@ -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() + + 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") +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index a235daf8d..f6609e3ba 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -9,6 +9,11 @@ import { getChannelsCatalog, patchAppConfig, } from "@/api/channels" +import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field" +import { + normalizeAllowFromValues, + serializeStringArrayForSubmit, +} from "@/components/channels/channel-array-utils" import { SECRET_FIELD_MAP, buildEditConfig, @@ -48,6 +53,43 @@ function asBool(value: unknown): boolean { return value === true } +function setRecordValueByPath( + source: Record, + pathSegments: string[], + value: unknown, +): Record { + 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([ "allow_from", "group_trigger", @@ -82,12 +124,20 @@ function buildSavePayload( if (key.startsWith("_")) continue if (key === "enabled") continue if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) { - payload[key] = value + if (key === "allow_from") { + payload[key] = serializeStringArrayForSubmit( + normalizeAllowFromValues(value), + ) + } else if (key === "group_trigger") { + payload[key] = serializeGroupTriggerForSubmit(value) + } else { + payload[key] = value + } continue } if (isSecretField(key)) continue - settings[key] = value + settings[key] = serializeStringArrayForSubmit(value) } for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { @@ -244,6 +294,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [editConfig, setEditConfig] = useState({}) const [configuredSecrets, setConfiguredSecrets] = useState([]) const [enabled, setEnabled] = useState(false) + const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0) + const arrayFieldFlushersRef = useRef(new Map()) const loadData = useCallback( async (silent = false) => { @@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { previousGatewayStatusRef.current = gatewayState }, [gatewayState, loadData]) - const savePayload = useMemo(() => { - if (!channel) return null - return buildSavePayload(channel, editConfig, enabled) - }, [channel, editConfig, enabled]) - const configured = useMemo(() => { if (!channel) return false 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 = () => { if (!channel) return setEditConfig(buildEditConfig(channel.name, baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) } const handleSave = async () => { - if (!channel || !savePayload) return + if (!channel) return + + const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig) + if (preparedEditConfig !== editConfig) { + setEditConfig(preparedEditConfig) + } const missingRequiredFields = requiredKeys.filter((key) => isMissingRequiredValue( - getFieldValueForValidation(editConfig, configuredSecrets, key), + getFieldValueForValidation(preparedEditConfig, configuredSecrets, key), ), ) if (missingRequiredFields.length > 0) { @@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) try { + const savePayload = buildSavePayload(channel, preparedEditConfig, enabled) await patchAppConfig({ channel_list: { [channel.config_key]: savePayload, @@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "discord": @@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "slack": @@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "feishu": @@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "weixin": @@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} isEdit={isEdit} onBindSuccess={() => void handleWeixinBindSuccess()} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "wecom": @@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={[...hiddenKeys, "bot_id"]} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) @@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={hiddenKeys} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) } diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index f72e1c5c7..d2a98d325 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" 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 { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface DiscordFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } 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") -} - function asBool(value: unknown): boolean { return value === true } @@ -38,6 +46,8 @@ export function DiscordForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) @@ -78,24 +88,17 @@ export function DiscordForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { @@ -21,16 +34,13 @@ function asBool(value: unknown): boolean { 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({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: FeishuFormProps) { const { t } = useTranslation() @@ -104,24 +114,17 @@ export function FeishuForm({ /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
+ registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } // 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 : "" } -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 { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -71,6 +79,8 @@ export function GenericForm({ hiddenKeys = [], requiredKeys = [], fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: GenericFormProps) { const { t } = useTranslation() const hiddenFieldSet = new Set(hiddenKeys) @@ -187,26 +197,18 @@ export function GenericForm({ if (Array.isArray(value)) { return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - + value={asStringArray(value)} + onChange={(nextValue) => onChange(key, nextValue)} + fieldPath={key} + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> ) } @@ -281,46 +283,31 @@ export function GenericForm({ {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_origins !== undefined && !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - + value={asStringArray(config.allow_origins)} + onChange={(value) => onChange("allow_origins", value)} + placeholder={t("channels.field.allowOriginsPlaceholder")} + fieldPath="allow_origins" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_token_query !== undefined && @@ -356,26 +343,21 @@ export function GenericForm({ />
- - - onChange("group_trigger", { - ...groupTriggerConfig, - prefixes: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - + value={asStringArray(groupTriggerConfig.prefixes)} + onChange={(value) => + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: value, + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + fieldPath="group_trigger.prefixes" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 14ffa0913..b8184e8bc 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,32 +1,41 @@ import { useTranslation } from "react-i18next" 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 { Field, KeyInput } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } 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 SlackForm({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: SlackFormProps) { const { t } = useTranslation() @@ -72,24 +81,17 @@ export function SlackForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 696da245d..f9c7c778a 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" 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 { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface TelegramFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } 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") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -38,6 +46,8 @@ export function TelegramForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) @@ -91,24 +101,17 @@ export function TelegramForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void isEdit: boolean onBindSuccess?: () => void + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } 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, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: WeixinFormProps) { const { t } = useTranslation() @@ -321,24 +331,17 @@ export function WeixinForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />