From f2f6987f00c57950b7cf2f1a2298f154e176051f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Tue, 24 Mar 2026 19:27:29 +0800 Subject: [PATCH 1/3] test(agent): allow mock custom tool args (#1965) --- pkg/agent/loop_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 976d25c4b..1a4a44edf 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -636,8 +636,9 @@ func (m *mockCustomTool) Description() string { func (m *mockCustomTool) Parameters() map[string]any { return map[string]any{ - "type": "object", - "properties": map[string]any{}, + "type": "object", + "properties": map[string]any{}, + "additionalProperties": true, } } From 8b6cbd99090908e2ccbd56e18ca06cf9a9283ee5 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:02:58 +0800 Subject: [PATCH 2/3] Fix: Prevent security.yml from being overwritten during config migration (#1966) --- pkg/config/config.go | 12 ++ pkg/config/migration_integration_test.go | 115 +++++++++++++++++++ pkg/config/security.go | 136 +++++++++++++++++++++++ 3 files changed, 263 insertions(+) diff --git a/pkg/config/config.go b/pkg/config/config.go index b281824ce..84e1ab61a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1395,6 +1395,18 @@ func LoadConfig(path string) (*Config, error) { if err != nil { return nil, err } + // Load existing security config and merge with migrated one to prevent data loss + existingSec, secErr := loadSecurityConfig(securityPath(path)) + if secErr != nil { + logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr}) + } + if existingSec != nil && cfg.security != nil { + cfg.security = mergeSecurityConfig(existingSec, cfg.security) + // Re-apply the merged security config to update all channels and models + if err = applySecurityConfig(cfg, cfg.security); err != nil { + logger.WarnF("failed to re-apply merged security config during migration", map[string]any{"error": err}) + } + } defer func(cfg *Config) { _ = SaveConfig(path, cfg) }(cfg) diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go index c884a6b5d..49d2a5831 100644 --- a/pkg/config/migration_integration_test.go +++ b/pkg/config/migration_integration_test.go @@ -566,3 +566,118 @@ func TestMigration_Integration_ModelNameField(t *testing.T) { t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat") } } + +// TestMigration_PreservesExistingSecurityConfig tests that when migrating from v0 to v1, +// existing .security.yml values (e.g., loaded from environment variables) are preserved +// and not overwritten by empty values from the legacy config. +func TestMigration_PreservesExistingSecurityConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + securityPath := filepath.Join(tmpDir, ".security.yml") + + // Create a legacy config (version 0) with model_list and channel config + // The model_list doesn't have api_keys, they should come from existing .security.yml + legacyConfig := `{ + "agents": { + "defaults": { + "provider": "openai", + "model": "gpt-4" + } + }, + "model_list": [ + { + "model_name": "openai", + "model": "openai/gpt-4" + } + ], + "channels": { + "telegram": { + "enabled": true + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "tools": { + "web": {"enabled": true} + }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false + } + }` + + // Create an existing .security.yml with values that might come from env vars + existingSecurity := `model_list: + openai:0: + api_keys: + - sk-existing-key-from-env +channels: + telegram: + token: existing-telegram-token-from-env + discord: + token: existing-discord-token-from-env +web: + brave: + api_keys: + - existing-brave-key +` + + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("Failed to write legacy config: %v", err) + } + + if err := os.WriteFile(securityPath, []byte(existingSecurity), 0o600); err != nil { + t.Fatalf("Failed to write existing security config: %v", err) + } + + // Load the config - this should trigger migration + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Verify that the migrated config has the existing security values + // Telegram token should be preserved + if cfg.Channels.Telegram.Token() != "existing-telegram-token-from-env" { + t.Errorf("Telegram token was overwritten: got %q, want %q", + cfg.Channels.Telegram.Token(), "existing-telegram-token-from-env") + } + + // Discord token should be preserved (even though legacy config didn't have it) + if cfg.Channels.Discord.Token() != "existing-discord-token-from-env" { + t.Errorf("Discord token was overwritten: got %q, want %q", + cfg.Channels.Discord.Token(), "existing-discord-token-from-env") + } + + // Model API key should be preserved + if cfg.ModelList[0].APIKey() != "sk-existing-key-from-env" { + t.Errorf("Model API key was overwritten: got %q, want %q", + cfg.ModelList[0].APIKey(), "sk-existing-key-from-env") + } + + // Brave API key should be preserved + if cfg.Tools.Web.Brave.APIKey() != "existing-brave-key" { + t.Errorf("Brave API key was overwritten: got %q, want %q", + cfg.Tools.Web.Brave.APIKey(), "existing-brave-key") + } + + // Reload the security config from disk to verify it wasn't corrupted + reloadedSec, err := loadSecurityConfig(securityPath) + if err != nil { + t.Fatalf("Failed to reload security config: %v", err) + } + + if reloadedSec.Channels.Telegram == nil || + reloadedSec.Channels.Telegram.Token != "existing-telegram-token-from-env" { + t.Error("Telegram token not preserved in .security.yml file") + } + + if reloadedSec.Channels.Discord == nil || reloadedSec.Channels.Discord.Token != "existing-discord-token-from-env" { + t.Error("Discord token not preserved in .security.yml file") + } +} diff --git a/pkg/config/security.go b/pkg/config/security.go index 5c71bf8c3..da989ca88 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -244,6 +244,142 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error { return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600) } +// mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'. +// This is used during config migration to preserve existing security data while adding new entries. +func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig { + if existing == nil { + return normalizeSecurityConfig(newer) + } + if newer == nil { + return normalizeSecurityConfig(existing) + } + + result := normalizeSecurityConfig(nil) + + // Merge ModelList: prefer newer if it has keys, otherwise use existing + for k, v := range existing.ModelList { + result.ModelList[k] = v + } + for k, v := range newer.ModelList { + if len(v.APIKeys) > 0 { + result.ModelList[k] = v + } + } + + // Merge Channels + if existing.Channels != nil { + result.Channels = existing.Channels + } + if newer.Channels != nil { + if result.Channels == nil { + result.Channels = &ChannelsSecurity{} + } + mergeChannelsSecurity(result.Channels, newer.Channels) + } + + // Merge Web + if existing.Web != nil { + result.Web = existing.Web + } + if newer.Web != nil { + if result.Web == nil { + result.Web = &WebToolsSecurity{} + } + mergeWebToolsSecurity(result.Web, newer.Web) + } + + // Merge Skills + if existing.Skills != nil { + result.Skills = existing.Skills + } + if newer.Skills != nil { + if result.Skills == nil { + result.Skills = &SkillsSecurity{} + } + mergeSkillsSecurity(result.Skills, newer.Skills) + } + + return result +} + +func mergeChannelsSecurity(dst, src *ChannelsSecurity) { + if src.Telegram != nil && src.Telegram.Token != "" { + dst.Telegram = src.Telegram + } + if src.Feishu != nil && + (src.Feishu.AppSecret != "" || src.Feishu.EncryptKey != "" || src.Feishu.VerificationToken != "") { + dst.Feishu = src.Feishu + } + if src.Discord != nil && src.Discord.Token != "" { + dst.Discord = src.Discord + } + if src.Weixin != nil && src.Weixin.Token != "" { + dst.Weixin = src.Weixin + } + if src.QQ != nil && src.QQ.AppSecret != "" { + dst.QQ = src.QQ + } + if src.DingTalk != nil && src.DingTalk.ClientSecret != "" { + dst.DingTalk = src.DingTalk + } + if src.Slack != nil && (src.Slack.BotToken != "" || src.Slack.AppToken != "") { + dst.Slack = src.Slack + } + if src.Matrix != nil && src.Matrix.AccessToken != "" { + dst.Matrix = src.Matrix + } + if src.LINE != nil && (src.LINE.ChannelSecret != "" || src.LINE.ChannelAccessToken != "") { + dst.LINE = src.LINE + } + if src.OneBot != nil && src.OneBot.AccessToken != "" { + dst.OneBot = src.OneBot + } + if src.WeCom != nil && (src.WeCom.Token != "" || src.WeCom.EncodingAESKey != "") { + dst.WeCom = src.WeCom + } + if src.WeComApp != nil && + (src.WeComApp.CorpSecret != "" || src.WeComApp.Token != "" || src.WeComApp.EncodingAESKey != "") { + dst.WeComApp = src.WeComApp + } + if src.WeComAIBot != nil && + (src.WeComAIBot.Secret != "" || src.WeComAIBot.Token != "" || src.WeComAIBot.EncodingAESKey != "") { + dst.WeComAIBot = src.WeComAIBot + } + if src.Pico != nil && src.Pico.Token != "" { + dst.Pico = src.Pico + } + if src.IRC != nil && (src.IRC.Password != "" || src.IRC.NickServPassword != "" || src.IRC.SASLPassword != "") { + dst.IRC = src.IRC + } +} + +func mergeWebToolsSecurity(dst, src *WebToolsSecurity) { + if src.Brave != nil && len(src.Brave.APIKeys) > 0 { + dst.Brave = src.Brave + } + if src.Tavily != nil && len(src.Tavily.APIKeys) > 0 { + dst.Tavily = src.Tavily + } + if src.Perplexity != nil && len(src.Perplexity.APIKeys) > 0 { + dst.Perplexity = src.Perplexity + } + if src.GLMSearch != nil && src.GLMSearch.APIKey != "" { + dst.GLMSearch = src.GLMSearch + } + if src.BaiduSearch != nil && src.BaiduSearch.APIKey != "" { + dst.BaiduSearch = src.BaiduSearch + } +} + +func mergeSkillsSecurity(dst, src *SkillsSecurity) { + if src.Github != nil && src.Github.Token != "" { + dst.Github = src.Github + } + if src.ClawHub != nil && src.ClawHub.AuthToken != "" { + dst.ClawHub = src.ClawHub + } +} + // SensitiveDataCache caches the compiled regex for filtering sensitive data. // SensitiveDataCache caches the strings.Replacer for filtering sensitive data. // Computed once on first access via sync.Once. From 4d7a629b7996145ff16a662832261c3e8b7954ed Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 24 Mar 2026 20:33:32 +0800 Subject: [PATCH 3/3] feat(web): improve Weixin channel binding flow (#1968) - persist Weixin bindings, enable the channel automatically, and try to restart the gateway - refresh frontend channel and gateway state after successful binding - harden QR polling state handling and update related channel UI behavior - localize sidebar channel priority, add Weixin icon support, and add backend test coverage --- web/backend/api/weixin.go | 25 +++- web/backend/api/weixin_test.go | 56 ++++++++ web/frontend/src/api/channels.ts | 8 +- web/frontend/src/components/app-sidebar.tsx | 7 +- .../channels/channel-config-page.tsx | 104 ++++++++------ .../channels/channel-forms/weixin-form.tsx | 133 ++++++++++++++---- .../src/components/chat/user-message.tsx | 2 +- .../src/components/config/form-model.ts | 5 +- .../src/hooks/use-sidebar-channels.ts | 31 ++-- web/frontend/src/i18n/locales/en.json | 7 +- web/frontend/src/i18n/locales/zh.json | 7 +- 11 files changed, 290 insertions(+), 95 deletions(-) create mode 100644 web/backend/api/weixin_test.go diff --git a/web/backend/api/weixin.go b/web/backend/api/weixin.go index e7e94f39e..808b88c41 100644 --- a/web/backend/api/weixin.go +++ b/web/backend/api/weixin.go @@ -171,7 +171,7 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { h.setWeixinFlowError(flowID, "login confirmed but missing bot_token") break } - if saveErr := h.saveWeixinToken(statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil { + if saveErr := h.saveWeixinBinding(statusResp.BotToken, statusResp.IlinkBotID); saveErr != nil { h.setWeixinFlowError(flowID, fmt.Sprintf("failed to save token: %v", saveErr)) logger.ErrorCF("weixin", "failed to save token", map[string]any{"error": saveErr.Error()}) break @@ -203,17 +203,34 @@ func (h *Handler) handlePollWeixinFlow(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } -// saveWeixinToken writes the token and account ID into the config file. -func (h *Handler) saveWeixinToken(token, accountID string) error { +// saveWeixinBinding writes the token/account ID, enables the Weixin channel, +// and best-effort restarts the gateway when it is currently running. +func (h *Handler) saveWeixinBinding(token, accountID string) error { cfg, err := config.LoadConfig(h.configPath) if err != nil { return fmt.Errorf("load config: %w", err) } cfg.Channels.Weixin.SetToken(token) + cfg.Channels.Weixin.Enabled = true if accountID != "" { cfg.Channels.Weixin.AccountID = accountID } - return config.SaveConfig(h.configPath, cfg) + if err := config.SaveConfig(h.configPath, cfg); err != nil { + return err + } + + status := h.gatewayStatusData() + gatewayStatus, _ := status["gateway_status"].(string) + if gatewayStatus != "running" { + return nil + } + + if _, err := h.RestartGateway(); err != nil { + logger.ErrorCF("weixin", "failed to restart gateway after saving binding", map[string]any{ + "error": err.Error(), + }) + } + return nil } // generateQRDataURI encodes content as a QR code PNG and returns a data URI. diff --git a/web/backend/api/weixin_test.go b/web/backend/api/weixin_test.go new file mode 100644 index 000000000..03342b72b --- /dev/null +++ b/web/backend/api/weixin_test.go @@ -0,0 +1,56 @@ +package api + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + originalHealthGet := gatewayHealthGet + gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(os.Getpid()) + `}`, + )), + }, nil + } + t.Cleanup(func() { + gatewayHealthGet = originalHealthGet + }) + + h := NewHandler(configPath) + if err := h.saveWeixinBinding("bot-token", "bot-account"); err != nil { + t.Fatalf("saveWeixinBinding() error = %v, want nil after config save succeeds", err) + } + + savedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := savedCfg.Channels.Weixin.Token(); got != "bot-token" { + t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token") + } + if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" { + t.Fatalf("Weixin.AccountID = %q, want %q", got, "bot-account") + } + if !savedCfg.Channels.Weixin.Enabled { + t.Fatalf("Weixin.Enabled = false, want true") + } +} diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index c3d3a65f3..d4c3ac74b 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -76,8 +76,12 @@ export async function startWeixinFlow(): Promise { return request("/api/weixin/flows", { method: "POST" }) } -export async function pollWeixinFlow(flowID: string): Promise { - return request(`/api/weixin/flows/${encodeURIComponent(flowID)}`) +export async function pollWeixinFlow( + flowID: string, +): Promise { + return request( + `/api/weixin/flows/${encodeURIComponent(flowID)}`, + ) } export type { ChannelsCatalogResponse, ConfigActionResponse } diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 702212857..0e135c0c1 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -67,14 +67,17 @@ const baseNavGroups: Omit[] = [ export function AppSidebar({ ...props }: React.ComponentProps) { const routerState = useRouterState() - const { t } = useTranslation() + const { i18n, t } = useTranslation() const currentPath = routerState.location.pathname const { channelItems, hasMoreChannels, showAllChannels, toggleShowAllChannels, - } = useSidebarChannels({ t }) + } = useSidebarChannels({ + language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(), + t, + }) const navGroups: NavGroup[] = React.useMemo(() => { return [ diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index 4996a6314..ee483d652 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -1,8 +1,6 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useAtomValue } from "jotai" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { toast } from "sonner" import { type ChannelConfig, @@ -21,7 +19,8 @@ import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" -import { gatewayAtom } from "@/store/gateway" +import { useGateway } from "@/hooks/use-gateway" +import { refreshGatewayState } from "@/store/gateway" interface ChannelConfigPageProps { channelName: string @@ -241,7 +240,7 @@ const CHANNELS_WITHOUT_DOCS = new Set([ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const { t, i18n } = useTranslation() - const gateway = useAtomValue(gatewayAtom) + const { state: gatewayState } = useGateway() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -254,56 +253,59 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [editConfig, setEditConfig] = useState({}) const [enabled, setEnabled] = useState(false) - const loadData = useCallback(async (silent = false) => { - if (!silent) setLoading(true) - try { - const [catalog, appConfig] = await Promise.all([ - getChannelsCatalog(), - getAppConfig(), - ]) - const matched = - catalog.channels.find((item) => item.name === channelName) ?? null + const loadData = useCallback( + async (silent = false) => { + if (!silent) setLoading(true) + try { + const [catalog, appConfig] = await Promise.all([ + getChannelsCatalog(), + getAppConfig(), + ]) + const matched = + catalog.channels.find((item) => item.name === channelName) ?? null - if (!matched) { - setChannel(null) - setFetchError( - t("channels.page.notFound", { - name: channelName, - }), - ) - return + if (!matched) { + setChannel(null) + setFetchError( + t("channels.page.notFound", { + name: channelName, + }), + ) + return + } + + const channelsConfig = asRecord(asRecord(appConfig).channels) + const raw = asRecord(channelsConfig[matched.config_key]) + const normalized = normalizeConfig(matched, raw) + + setChannel(matched) + setBaseConfig(normalized) + setEditConfig(buildEditConfig(normalized)) + setEnabled(asBool(normalized.enabled)) + setFetchError("") + setServerError("") + setFieldErrors({}) + } catch (e) { + setFetchError(e instanceof Error ? e.message : t("channels.loadError")) + } finally { + if (!silent) setLoading(false) } - - const channelsConfig = asRecord(asRecord(appConfig).channels) - const raw = asRecord(channelsConfig[matched.config_key]) - const normalized = normalizeConfig(matched, raw) - - setChannel(matched) - setBaseConfig(normalized) - setEditConfig(buildEditConfig(normalized)) - setEnabled(asBool(normalized.enabled)) - setFetchError("") - setServerError("") - setFieldErrors({}) - } catch (e) { - setFetchError(e instanceof Error ? e.message : t("channels.loadError")) - } finally { - if (!silent) setLoading(false) - } - }, [channelName, t]) + }, + [channelName, t], + ) useEffect(() => { loadData() }, [loadData]) - const previousGatewayStatusRef = useRef(gateway.status) + const previousGatewayStatusRef = useRef(gatewayState) useEffect(() => { const previousStatus = previousGatewayStatusRef.current - if (previousStatus !== "running" && gateway.status === "running") { + if (previousStatus !== "running" && gatewayState === "running") { void loadData() } - previousGatewayStatusRef.current = gateway.status - }, [gateway.status, loadData]) + previousGatewayStatusRef.current = gatewayState + }, [gatewayState, loadData]) const savePayload = useMemo(() => { if (!channel) return null @@ -396,18 +398,28 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { [channel.config_key]: savePayload, }, }) - toast.success(t("channels.page.saveSuccess")) await loadData() } catch (e) { const message = e instanceof Error ? e.message : t("channels.page.saveError") setServerError(message) - toast.error(message) } finally { setSaving(false) } } + const handleWeixinBindSuccess = useCallback(async () => { + try { + setEnabled(true) + await Promise.all([loadData(true), refreshGatewayState({ force: true })]) + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + await loadData(true) + } + }, [loadData, t]) + const renderForm = () => { if (!channel) return null const isEdit = configured @@ -455,7 +467,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { config={editConfig} onChange={handleChange} isEdit={isEdit} - onBindSuccess={() => void loadData(true)} + onBindSuccess={() => void handleWeixinBindSuccess()} /> ) default: diff --git a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx index 765136b25..20e66ffc2 100644 --- a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx @@ -1,4 +1,10 @@ -import { IconLoader2, IconRefresh, IconCheck, IconX, IconQrcode } from "@tabler/icons-react" +import { + IconCheck, + IconLoader2, + IconQrcode, + IconRefresh, + IconX, +} from "@tabler/icons-react" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -8,7 +14,14 @@ import { Field } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -type BindingState = "idle" | "loading" | "waiting" | "scaned" | "confirmed" | "expired" | "error" +type BindingState = + | "idle" + | "loading" + | "waiting" + | "scaned" + | "confirmed" + | "expired" + | "error" interface WeixinFormProps { config: ChannelConfig @@ -26,7 +39,12 @@ function asStringArray(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string") } -export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFormProps) { +export function WeixinForm({ + config, + onChange, + isEdit, + onBindSuccess, +}: WeixinFormProps) { const { t } = useTranslation() const [bindState, setBindState] = useState("idle") @@ -35,10 +53,12 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo const [errorMsg, setErrorMsg] = useState("") const pollTimerRef = useRef | null>(null) + const pollGenerationRef = useRef(0) const isBound = isEdit && asString(config.account_id) !== "" const existingAccountID = asString(config.account_id) const stopPolling = useCallback(() => { + pollGenerationRef.current += 1 if (pollTimerRef.current !== null) { clearInterval(pollTimerRef.current) pollTimerRef.current = null @@ -47,17 +67,32 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo useEffect(() => () => stopPolling(), [stopPolling]) + useEffect(() => { + if (!existingAccountID) return + stopPolling() + setAccountID(existingAccountID) + setBindState("confirmed") + setErrorMsg("") + }, [existingAccountID, stopPolling]) + const startPolling = useCallback( (id: string) => { stopPolling() + const generation = pollGenerationRef.current + let inFlight = false pollTimerRef.current = setInterval(async () => { + if (inFlight) return + inFlight = true try { const resp = await pollWeixinFlow(id) + if (generation !== pollGenerationRef.current) { + return + } if (resp.status === "scaned") { setBindState("scaned") } else if (resp.status === "confirmed") { stopPolling() - setAccountID(resp.account_id ?? null) + setAccountID(resp.account_id ?? existingAccountID ?? null) setBindState("confirmed") onBindSuccess?.() } else if (resp.status === "expired") { @@ -70,10 +105,12 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo } } catch { // transient network error — keep polling + } finally { + inFlight = false } }, 2000) }, - [stopPolling, onBindSuccess, t], + [existingAccountID, stopPolling, onBindSuccess, t], ) const handleBind = async () => { @@ -88,7 +125,9 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo startPolling(resp.flow_id) } catch (e) { setBindState("error") - setErrorMsg(e instanceof Error ? e.message : t("channels.weixin.errorGeneric")) + setErrorMsg( + e instanceof Error ? e.message : t("channels.weixin.errorGeneric"), + ) } } @@ -111,9 +150,16 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo {t("channels.weixin.bound")} {existingAccountID && ( -

{existingAccountID}

+

+ {existingAccountID} +

)} - @@ -122,7 +168,9 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo } return (
-

{t("channels.weixin.notBound")}

+

+ {t("channels.weixin.notBound")} +

@@ -174,15 +237,25 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo return (
- +

{t("channels.weixin.bound")}

{accountID && ( -

{accountID}

+

+ {accountID} +

)} - @@ -196,7 +269,9 @@ export function WeixinForm({ config, onChange, isEdit, onBindSuccess }: WeixinFo
-

{t("channels.weixin.expired")}

+

+ {t("channels.weixin.expired")} +