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} + />