diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 0add7594d..b9f6f42d9 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -52,6 +52,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { 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 { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } if execAllowRemoteOmitted(body) { cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote } @@ -63,6 +68,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) return } + applyConfigSecretsFromMap(&cfg, raw) if errs := validateConfig(&cfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -159,6 +165,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) return } + applyConfigSecretsFromMap(&newCfg, base) if errs := validateConfig(&newCfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -325,3 +332,140 @@ func mergeMap(dst, src map[string]any) { } } } + +func asMapField(value map[string]any, key string) (map[string]any, bool) { + raw, ok := value[key] + if !ok { + return nil, false + } + m, ok := raw.(map[string]any) + return m, ok +} + +func getSecretString(m map[string]any, key string) (string, bool) { + if raw, ok := m[key]; ok { + s, ok := raw.(string) + if ok { + return s, true + } + } + if raw, ok := m["_"+key]; ok { + s, ok := raw.(string) + if ok { + return s, true + } + } + return "", false +} + +func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) { + channels, ok := asMapField(raw, "channels") + if ok { + if telegram, ok := asMapField(channels, "telegram"); ok { + if token, ok := getSecretString(telegram, "token"); ok { + cfg.Channels.Telegram.SetToken(token) + } + } + if feishu, ok := asMapField(channels, "feishu"); ok { + if appSecret, ok := getSecretString(feishu, "app_secret"); ok { + cfg.Channels.Feishu.SetAppSecret(appSecret) + } + if encryptKey, ok := getSecretString(feishu, "encrypt_key"); ok { + cfg.Channels.Feishu.SetEncryptKey(encryptKey) + } + if verificationToken, ok := getSecretString(feishu, "verification_token"); ok { + cfg.Channels.Feishu.SetVerificationToken(verificationToken) + } + } + if discord, ok := asMapField(channels, "discord"); ok { + if token, ok := getSecretString(discord, "token"); ok { + cfg.Channels.Discord.SetToken(token) + } + } + if weixin, ok := asMapField(channels, "weixin"); ok { + if token, ok := getSecretString(weixin, "token"); ok { + cfg.Channels.Weixin.SetToken(token) + } + } + if qq, ok := asMapField(channels, "qq"); ok { + if appSecret, ok := getSecretString(qq, "app_secret"); ok { + cfg.Channels.QQ.SetAppSecret(appSecret) + } + } + if dingtalk, ok := asMapField(channels, "dingtalk"); ok { + if clientSecret, ok := getSecretString(dingtalk, "client_secret"); ok { + cfg.Channels.DingTalk.SetClientSecret(clientSecret) + } + } + if slack, ok := asMapField(channels, "slack"); ok { + if botToken, ok := getSecretString(slack, "bot_token"); ok { + cfg.Channels.Slack.SetBotToken(botToken) + } + if appToken, ok := getSecretString(slack, "app_token"); ok { + cfg.Channels.Slack.SetAppToken(appToken) + } + } + if matrix, ok := asMapField(channels, "matrix"); ok { + if accessToken, ok := getSecretString(matrix, "access_token"); ok { + cfg.Channels.Matrix.SetAccessToken(accessToken) + } + } + if line, ok := asMapField(channels, "line"); ok { + if channelSecret, ok := getSecretString(line, "channel_secret"); ok { + cfg.Channels.LINE.SetChannelSecret(channelSecret) + } + if channelAccessToken, ok := getSecretString(line, "channel_access_token"); ok { + cfg.Channels.LINE.SetChannelAccessToken(channelAccessToken) + } + } + if onebot, ok := asMapField(channels, "onebot"); ok { + if accessToken, ok := getSecretString(onebot, "access_token"); ok { + cfg.Channels.OneBot.SetAccessToken(accessToken) + } + } + if wecom, ok := asMapField(channels, "wecom"); ok { + if secret, ok := getSecretString(wecom, "secret"); ok { + cfg.Channels.WeCom.SetSecret(secret) + } + } + if pico, ok := asMapField(channels, "pico"); ok { + if token, ok := getSecretString(pico, "token"); ok { + cfg.Channels.Pico.SetToken(token) + } + } + if irc, ok := asMapField(channels, "irc"); ok { + if password, ok := getSecretString(irc, "password"); ok { + cfg.Channels.IRC.SetPassword(password) + } + if nickservPassword, ok := getSecretString(irc, "nickserv_password"); ok { + cfg.Channels.IRC.SetNickServPassword(nickservPassword) + } + if saslPassword, ok := getSecretString(irc, "sasl_password"); ok { + cfg.Channels.IRC.SetSASLPassword(saslPassword) + } + } + } + + tools, ok := asMapField(raw, "tools") + if !ok { + return + } + skills, ok := asMapField(tools, "skills") + if !ok { + return + } + if github, ok := asMapField(skills, "github"); ok { + if token, ok := getSecretString(github, "token"); ok { + cfg.Tools.Skills.Github.SetToken(token) + } + } + registries, ok := asMapField(skills, "registries") + if !ok { + return + } + if clawHub, ok := asMapField(registries, "clawhub"); ok { + if authToken, ok := getSecretString(clawHub, "auth_token"); ok { + cfg.Tools.Skills.Registries.ClawHub.SetAuthToken(authToken) + } + } +} diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 644284849..5196ace5b 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -251,6 +251,42 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { } } +func TestHandlePatchConfig_SavesDiscordTokenFromPayload(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(`{ + "channels": { + "discord": { + "enabled": true, + "token": "discord-test-token" + } + } + }`)) + 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) + } + if !cfg.Channels.Discord.Enabled { + t.Fatal("discord should be enabled after PATCH") + } + if got := cfg.Channels.Discord.Token(); got != "discord-test-token" { + t.Fatalf("discord token = %q, want %q", got, "discord-test-token") + } +} + func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index 6af821ac9..3890924e0 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -62,10 +62,8 @@ function asBool(value: unknown): boolean { function buildEditConfig(config: ChannelConfig): ChannelConfig { const edit: ChannelConfig = { ...config } - for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { - if (secretKey in config) { - edit[SECRET_FIELD_MAP[secretKey]] = "" - } + for (const editKey of Object.values(SECRET_FIELD_MAP)) { + edit[editKey] = "" } return edit } @@ -94,17 +92,22 @@ function buildSavePayload( for (const [key, value] of Object.entries(editConfig)) { if (key.startsWith("_")) continue if (key === "enabled") continue - - if (key in SECRET_FIELD_MAP) { - const editKey = SECRET_FIELD_MAP[key] - const incoming = asString(editConfig[editKey]) - payload[key] = incoming !== "" ? incoming : value - continue - } + if (key in SECRET_FIELD_MAP) continue payload[key] = value } + for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { + const incoming = asString(editConfig[editKey]) + if (incoming !== "") { + payload[secretKey] = incoming + continue + } + if (secretKey in editConfig) { + payload[secretKey] = editConfig[secretKey] + } + } + if (channel.name === "whatsapp_native") { payload.use_native = true }