diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 92d15cb22..e27c4eb02 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -980,6 +980,7 @@ func buildLarkConnectorFactory(installSvc *lark.InstallationService, apiClient l creds := lark.InstallationCredentials{ AppID: inst.AppID, AppSecret: secret, + Region: lark.RegionOrDefault(inst.Region), } if inst.TenantKey.Valid { creds.TenantKey = inst.TenantKey.String diff --git a/server/internal/handler/lark.go b/server/internal/handler/lark.go index d6810d565..0902b8980 100644 --- a/server/internal/handler/lark.go +++ b/server/internal/handler/lark.go @@ -28,9 +28,13 @@ type LarkInstallationResponse struct { BotOpenID string `json:"bot_open_id"` InstallerUserID string `json:"installer_user_id"` Status string `json:"status"` - InstalledAt string `json:"installed_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // Region is the Lark cloud this installation lives on: "feishu" + // (mainland) or "lark" (international). The UI uses it to render a + // badge and to build the correct "Manage in Lark" dev-console host. + Region string `json:"region"` + InstalledAt string `json:"installed_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationResponse { @@ -42,6 +46,7 @@ func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationRespons BotOpenID: row.BotOpenID, InstallerUserID: uuidToString(row.InstallerUserID), Status: row.Status, + Region: row.Region, InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339), CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339), UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339), diff --git a/server/internal/integrations/lark/client.go b/server/internal/integrations/lark/client.go index 909091ac8..c11e401be 100644 --- a/server/internal/integrations/lark/client.go +++ b/server/internal/integrations/lark/client.go @@ -202,6 +202,14 @@ type InstallationCredentials struct { AppID string AppSecret string TenantKey string + // Region selects the Lark open-platform host (Feishu mainland vs + // Lark international) for every call made with these credentials. + // Empty defaults to Feishu. Credential-build sites copy it from + // lark_installation.region; the device-flow installer sets it from + // the auto-detected tenant. This is what lets one deployment serve + // both clouds — see http_client.go resolveBaseURL and + // ws_endpoint.go Endpoint. + Region Region } // ErrAPIClientNotConfigured is returned by the stub client to signal diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 6a3ab1da5..77da48729 100644 --- a/server/internal/integrations/lark/http_client.go +++ b/server/internal/integrations/lark/http_client.go @@ -60,9 +60,14 @@ const ( // HTTPClientConfig configures the production Lark HTTP APIClient. type HTTPClientConfig struct { - // BaseURL is the Lark open-platform root, e.g. - // "https://open.feishu.cn" or "https://open.larksuite.com". Empty - // defaults to defaultLarkBaseURL. Trailing "/" is stripped. + // BaseURL is an optional deployment-wide override for the Lark + // open-platform root, e.g. "https://open.feishu.cn" or + // "https://open.larksuite.com". When set it forces every call — + // regardless of the installation's region — to that host; tests set + // it to an httptest.Server URL. When EMPTY (the production default), + // each call resolves its host from InstallationCredentials.Region so + // a single deployment serves both Feishu and Lark. Trailing "/" is + // stripped. BaseURL string // HTTPClient is the transport used for every outbound call. Tests @@ -80,9 +85,12 @@ type HTTPClientConfig struct { } func (c HTTPClientConfig) withDefaults() HTTPClientConfig { - if c.BaseURL == "" { - c.BaseURL = defaultLarkBaseURL - } + // BaseURL is intentionally NOT defaulted to defaultLarkBaseURL here. + // An empty BaseURL means "no deployment-wide override" — each call + // then resolves its host from InstallationCredentials.Region (see + // resolveBaseURL), so one client serves both Feishu and Lark. A + // non-empty BaseURL (MULTICA_LARK_HTTP_BASE_URL, or an httptest URL + // in tests) forces every region to that host. c.BaseURL = strings.TrimRight(c.BaseURL, "/") if c.HTTPClient == nil { c.HTTPClient = &http.Client{Timeout: defaultRequestTimeout} @@ -165,7 +173,7 @@ func (c *httpAPIClient) tenantAccessToken(ctx context.Context, creds Installatio TenantAccessToken string `json:"tenant_access_token"` Expire int64 `json:"expire"` } - if err := c.doJSON(ctx, http.MethodPost, "/open-apis/auth/v3/tenant_access_token/internal", "", body, &resp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(creds), http.MethodPost, "/open-apis/auth/v3/tenant_access_token/internal", "", body, &resp); err != nil { return "", fmt.Errorf("lark http client: tenant_access_token: %w", err) } if resp.Code != 0 || resp.TenantAccessToken == "" { @@ -188,6 +196,18 @@ func (c *httpAPIClient) tenantAccessToken(ctx context.Context, creds Installatio return resp.TenantAccessToken, nil } +// resolveBaseURL picks the open-platform host for one call. An explicit +// cfg.BaseURL (MULTICA_LARK_HTTP_BASE_URL, or an httptest URL in tests) +// overrides every region and routes all traffic there. With no override, +// the host comes from the installation's region, so Feishu and Lark +// installations served by the same process each reach their own cloud. +func (c *httpAPIClient) resolveBaseURL(creds InstallationCredentials) string { + if c.cfg.BaseURL != "" { + return c.cfg.BaseURL + } + return creds.Region.OpenPlatformBaseURL() +} + // invalidateToken drops the cached token for an app_id. Called when // Lark surfaces an expired / invalid token error code so the next // call refreshes instead of looping on a stale entry. @@ -226,7 +246,7 @@ func (c *httpAPIClient) SendInteractiveCard(ctx context.Context, p SendCardParam } `json:"data"` } path := "/open-apis/im/v1/messages?" + q.Encode() - if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(p.InstallationID), http.MethodPost, path, token, body, &resp); err != nil { return "", fmt.Errorf("lark http client: send interactive card: %w", err) } if resp.Code != 0 || resp.Data.MessageID == "" { @@ -277,7 +297,7 @@ func (c *httpAPIClient) SendTextMessage(ctx context.Context, p SendTextParams) ( } `json:"data"` } path := "/open-apis/im/v1/messages?" + q.Encode() - if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(p.InstallationID), http.MethodPost, path, token, body, &resp); err != nil { return "", fmt.Errorf("lark http client: send text message: %w", err) } if resp.Code != 0 || resp.Data.MessageID == "" { @@ -347,7 +367,7 @@ func (c *httpAPIClient) SendMarkdownCard(ctx context.Context, p SendMarkdownCard } `json:"data"` } path := "/open-apis/im/v1/messages?" + q.Encode() - if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(p.InstallationID), http.MethodPost, path, token, body, &resp); err != nil { return "", fmt.Errorf("lark http client: send markdown card: %w", err) } if resp.Code != 0 || resp.Data.MessageID == "" { @@ -379,7 +399,7 @@ func (c *httpAPIClient) PatchInteractiveCard(ctx context.Context, p PatchCardPar Msg string `json:"msg"` } path := "/open-apis/im/v1/messages/" + url.PathEscape(p.LarkCardMessageID) - if err := c.doJSON(ctx, http.MethodPatch, path, token, body, &resp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(p.InstallationID), http.MethodPatch, path, token, body, &resp); err != nil { return fmt.Errorf("lark http client: patch interactive card: %w", err) } if resp.Code != 0 { @@ -422,7 +442,7 @@ func (c *httpAPIClient) SendBindingPromptCard(ctx context.Context, p BindingProm Msg string `json:"msg"` } path := "/open-apis/im/v1/messages?" + q.Encode() - if err := c.doJSON(ctx, http.MethodPost, path, token, body, &resp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(p.InstallationID), http.MethodPost, path, token, body, &resp); err != nil { return fmt.Errorf("lark http client: send binding prompt: %w", err) } if resp.Code != 0 { @@ -478,7 +498,7 @@ func (c *httpAPIClient) GetBotInfo(ctx context.Context, creds InstallationCreden OpenID string `json:"open_id"` } `json:"bot"` } - if err := c.doJSON(ctx, http.MethodGet, "/open-apis/bot/v3/info", token, nil, &botResp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(creds), http.MethodGet, "/open-apis/bot/v3/info", token, nil, &botResp); err != nil { return BotInfo{}, fmt.Errorf("lark http client: bot info: %w", err) } if botResp.Code != 0 { @@ -495,7 +515,7 @@ func (c *httpAPIClient) GetBotInfo(ctx context.Context, creds InstallationCreden // return the BotInfo with empty UnionID. Callers (Registration- // Service.finishSuccess) accept the gap and persist what they // have. - unionID, lookupErr := c.fetchBotUnionID(ctx, creds.AppID, token, botResp.Bot.OpenID) + unionID, lookupErr := c.fetchBotUnionID(ctx, c.resolveBaseURL(creds), creds.AppID, token, botResp.Bot.OpenID) if lookupErr != nil { c.cfg.Logger.Warn("lark http client: bot union_id lookup failed; continuing without it", "app_id", creds.AppID, @@ -538,7 +558,7 @@ func (c *httpAPIClient) GetMessage(ctx context.Context, creds InstallationCreden Items []larkRESTMessageItem `json:"items"` } `json:"data"` } - if err := c.doJSON(ctx, http.MethodGet, path, token, nil, &resp); err != nil { + if err := c.doJSON(ctx, c.resolveBaseURL(creds), http.MethodGet, path, token, nil, &resp); err != nil { return nil, fmt.Errorf("lark http client: get message: %w", err) } if resp.Code != 0 { @@ -611,7 +631,7 @@ func (it larkRESTMessageItem) normalize() LarkMessage { // scope is restricted. Caller logs and continues; the decoder still // works in single-bot deployments where open_id-based matching is // unambiguous. -func (c *httpAPIClient) fetchBotUnionID(ctx context.Context, appID, token, openID string) (string, error) { +func (c *httpAPIClient) fetchBotUnionID(ctx context.Context, baseURL, appID, token, openID string) (string, error) { if openID == "" { return "", errors.New("empty open_id") } @@ -627,7 +647,7 @@ func (c *httpAPIClient) fetchBotUnionID(ctx context.Context, appID, token, openI } `json:"user"` } `json:"data"` } - if err := c.doJSON(ctx, http.MethodGet, path, token, nil, &resp); err != nil { + if err := c.doJSON(ctx, baseURL, http.MethodGet, path, token, nil, &resp); err != nil { return "", fmt.Errorf("contact users: %w", err) } if resp.Code != 0 { @@ -645,9 +665,11 @@ func (c *httpAPIClient) fetchBotUnionID(ctx context.Context, appID, token, openI // doJSON encapsulates the verb + URL + auth-header + JSON // encode/decode dance so each public method stays a thin shape-only -// adapter. token == "" skips the Authorization header (only the -// tenant_access_token endpoint takes that path). -func (c *httpAPIClient) doJSON(ctx context.Context, method, path, token string, body, out any) error { +// adapter. baseURL is the per-call open-platform host the caller +// resolved via resolveBaseURL (region-aware). token == "" skips the +// Authorization header (only the tenant_access_token endpoint takes +// that path). +func (c *httpAPIClient) doJSON(ctx context.Context, baseURL, method, path, token string, body, out any) error { var rdr io.Reader if body != nil { buf, err := json.Marshal(body) @@ -656,7 +678,7 @@ func (c *httpAPIClient) doJSON(ctx context.Context, method, path, token string, } rdr = bytes.NewReader(buf) } - req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+path, rdr) + req, err := http.NewRequestWithContext(ctx, method, baseURL+path, rdr) if err != nil { return fmt.Errorf("new request: %w", err) } diff --git a/server/internal/integrations/lark/hub.go b/server/internal/integrations/lark/hub.go index 63bd50089..5063ab798 100644 --- a/server/internal/integrations/lark/hub.go +++ b/server/internal/integrations/lark/hub.go @@ -493,7 +493,11 @@ func leaseToken(nodeID string, gen uint64) string { // secret is never extracted; the encrypted ciphertext is fine to hash. func installationFingerprint(inst db.LarkInstallation) string { sum := sha256.Sum256(inst.AppSecretEncrypted) - return inst.AppID + "|" + inst.BotOpenID + "|" + hex.EncodeToString(sum[:]) + // region is part of the fingerprint: if a re-install corrects the + // cloud (e.g. a row mis-detected as feishu is re-scanned as lark), + // the WS bootstrap host changes, so the running supervisor must be + // torn down and restarted against the new host. + return inst.AppID + "|" + inst.BotOpenID + "|" + inst.Region + "|" + hex.EncodeToString(sum[:]) } // supervise owns one installation's connection lifecycle. It loops: diff --git a/server/internal/integrations/lark/installation.go b/server/internal/integrations/lark/installation.go index 3e1def9de..f4e51c974 100644 --- a/server/internal/integrations/lark/installation.go +++ b/server/internal/integrations/lark/installation.go @@ -25,6 +25,7 @@ type InstallationParams struct { TenantKey string // optional, "" treated as NULL BotOpenID string InstallerUserID pgtype.UUID + Region Region // which cloud (feishu/lark); empty defaults to feishu } // InstallationService creates, refreshes and revokes per-agent Lark @@ -71,6 +72,7 @@ func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams) TenantKey: textOrNull(p.TenantKey), BotOpenID: p.BotOpenID, InstallerUserID: p.InstallerUserID, + Region: string(RegionOrDefault(string(p.Region))), }) } diff --git a/server/internal/integrations/lark/outbound.go b/server/internal/integrations/lark/outbound.go index d6cea31c3..61fe6d799 100644 --- a/server/internal/integrations/lark/outbound.go +++ b/server/internal/integrations/lark/outbound.go @@ -374,6 +374,7 @@ func (p *Patcher) installationCredentials(inst db.LarkInstallation) (Installatio creds := InstallationCredentials{ AppID: inst.AppID, AppSecret: secret, + Region: RegionOrDefault(inst.Region), } if inst.TenantKey.Valid { creds.TenantKey = inst.TenantKey.String diff --git a/server/internal/integrations/lark/outcome_replier.go b/server/internal/integrations/lark/outcome_replier.go index f487cbd09..21eec0e1e 100644 --- a/server/internal/integrations/lark/outcome_replier.go +++ b/server/internal/integrations/lark/outcome_replier.go @@ -303,6 +303,7 @@ func (r *LarkOutcomeReplier) installationCredentials(inst db.LarkInstallation) ( creds := InstallationCredentials{ AppID: inst.AppID, AppSecret: secret, + Region: RegionOrDefault(inst.Region), } if inst.TenantKey.Valid { creds.TenantKey = inst.TenantKey.String diff --git a/server/internal/integrations/lark/region_test.go b/server/internal/integrations/lark/region_test.go new file mode 100644 index 000000000..cb7e30d3a --- /dev/null +++ b/server/internal/integrations/lark/region_test.go @@ -0,0 +1,176 @@ +package lark + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +// capturingRoundTripper records the host of every outbound request and +// replies with a canned Lark-style JSON body that satisfies every decode +// path the client takes (token mint, bot info, contact union_id). It lets +// a test assert WHICH open-platform host a call targeted without dialing +// the real public Feishu / Lark domains. +type capturingRoundTripper struct { + hosts []string +} + +func (rt *capturingRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + rt.hosts = append(rt.hosts, r.URL.Host) + const body = `{"code":0,"msg":"ok","tenant_access_token":"t","expire":7200,` + + `"bot":{"open_id":"ou_x"},"data":{"user":{"union_id":"on_x"}}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil +} + +// TestRegion_OpenPlatformBaseURL pins the region→host mapping that both +// the REST client and the WS bootstrap depend on. +func TestRegion_OpenPlatformBaseURL(t *testing.T) { + cases := []struct { + region Region + want string + }{ + {RegionFeishu, "https://open.feishu.cn"}, + {RegionLark, "https://open.larksuite.com"}, + {Region(""), "https://open.feishu.cn"}, + {Region("bogus"), "https://open.feishu.cn"}, + } + for _, tc := range cases { + if got := tc.region.OpenPlatformBaseURL(); got != tc.want { + t.Errorf("Region(%q).OpenPlatformBaseURL() = %q, want %q", tc.region, got, tc.want) + } + } +} + +// TestRegionOrDefault pins the normalization used at every credential- +// build site: unknown / empty strings collapse to Feishu so a malformed +// row never yields an empty host or a CHECK-violating write. +func TestRegionOrDefault(t *testing.T) { + cases := map[string]Region{ + "feishu": RegionFeishu, + "lark": RegionLark, + "": RegionFeishu, + "LARK": RegionFeishu, // case-sensitive on purpose; CHECK stores lowercase + "intl": RegionFeishu, + } + for in, want := range cases { + if got := RegionOrDefault(in); got != want { + t.Errorf("RegionOrDefault(%q) = %q, want %q", in, got, want) + } + } +} + +// TestHTTPClient_ResolvesHostFromRegion is the core dual-region guarantee: +// with NO deployment-wide BaseURL override, the open-platform host is +// chosen per call from InstallationCredentials.Region, so Feishu and Lark +// installations served by one process each reach their own cloud. +func TestHTTPClient_ResolvesHostFromRegion(t *testing.T) { + cases := []struct { + name string + region Region + host string + }{ + {"feishu", RegionFeishu, "open.feishu.cn"}, + {"lark", RegionLark, "open.larksuite.com"}, + {"empty defaults to feishu", Region(""), "open.feishu.cn"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := &capturingRoundTripper{} + // No BaseURL → region resolution governs the host. + c := NewHTTPAPIClient(HTTPClientConfig{HTTPClient: &http.Client{Transport: rt}}) + if _, err := c.GetBotInfo(context.Background(), InstallationCredentials{ + AppID: "cli_x", AppSecret: "s", Region: tc.region, + }); err != nil { + t.Fatalf("GetBotInfo: %v", err) + } + if len(rt.hosts) == 0 { + t.Fatalf("no requests captured") + } + for _, h := range rt.hosts { + if h != tc.host { + t.Errorf("request targeted host %q, want %q", h, tc.host) + } + } + }) + } +} + +// TestHTTPClient_BaseURLOverridesRegion pins the test / staging seam: an +// explicit cfg.BaseURL forces every region to that host, which is how the +// existing test suite (and MULTICA_LARK_HTTP_BASE_URL) keeps working. +func TestHTTPClient_BaseURLOverridesRegion(t *testing.T) { + rt := &capturingRoundTripper{} + c := NewHTTPAPIClient(HTTPClientConfig{ + BaseURL: "https://override.example.com", + HTTPClient: &http.Client{Transport: rt}, + }) + if _, err := c.GetBotInfo(context.Background(), InstallationCredentials{ + AppID: "cli_x", AppSecret: "s", Region: RegionLark, // would be larksuite, but override wins + }); err != nil { + t.Fatalf("GetBotInfo: %v", err) + } + for _, h := range rt.hosts { + if h != "override.example.com" { + t.Errorf("override not honored: host=%q, want override.example.com", h) + } + } +} + +// TestWSEndpoint_ResolvesHostFromRegion pins that the long-conn bootstrap +// POST (/callback/ws/endpoint) also targets the per-installation region +// host when no deployment-wide override is set. +func TestWSEndpoint_ResolvesHostFromRegion(t *testing.T) { + cases := []struct { + region Region + host string + }{ + {RegionFeishu, "open.feishu.cn"}, + {RegionLark, "open.larksuite.com"}, + {Region(""), "open.feishu.cn"}, + } + for _, tc := range cases { + rt := &wsEndpointRoundTripper{} + f, err := NewHTTPConnectionTokenFetcher(HTTPConnectionTokenConfig{ + HTTPClient: &http.Client{Transport: rt}, + }) + if err != nil { + t.Fatalf("NewHTTPConnectionTokenFetcher: %v", err) + } + if _, err := f.Endpoint(context.Background(), InstallationCredentials{ + AppID: "cli_x", AppSecret: "s", Region: tc.region, + }); err != nil { + t.Fatalf("Endpoint(region=%q): %v", tc.region, err) + } + if rt.host != tc.host { + t.Errorf("ws bootstrap targeted host %q, want %q (region=%q)", rt.host, tc.host, tc.region) + } + if rt.path != "/callback/ws/endpoint" { + t.Errorf("ws bootstrap path = %q, want /callback/ws/endpoint", rt.path) + } + } +} + +// wsEndpointRoundTripper returns a valid endpointResponse so Endpoint's +// decode succeeds, while recording the host + path it was asked to reach. +type wsEndpointRoundTripper struct { + host string + path string +} + +func (rt *wsEndpointRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + rt.host = r.URL.Host + rt.path = r.URL.Path + const body = `{"code":0,"msg":"ok","data":{"URL":"wss://example/ws?service_id=1&device_id=d",` + + `"ClientConfig":{"ReconnectCount":1,"ReconnectInterval":120,"ReconnectNonce":30,"PingInterval":120}}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil +} diff --git a/server/internal/integrations/lark/registration_service.go b/server/internal/integrations/lark/registration_service.go index 7b81617c6..9a6932884 100644 --- a/server/internal/integrations/lark/registration_service.go +++ b/server/internal/integrations/lark/registration_service.go @@ -407,6 +407,14 @@ func (s *RegistrationService) runPolling(sess *registrationSession) { } domain := sess.domain deviceCode := sess.deviceCode + // region tracks which cloud this install belongs to. It starts at + // Feishu (the begin host) and flips to Lark the moment the poll + // stream surfaces tenant_brand="lark" (the SwitchedDomain branch + // below). At finishSuccess time it is the authoritative per-install + // region, derived from the protocol's own role-based switch rather + // than by string-matching accounts hostnames (so staging/mock + // domains classify correctly too). + region := RegionFeishu for { select { @@ -444,11 +452,12 @@ func (s *RegistrationService) runPolling(sess *registrationSession) { // transition poll and the credential-bearing response // lands on the next call to the new domain. domain = res.SwitchedDomain + region = RegionLark s.cfg.Logger.Info("lark registration: switched to lark-international domain", "session_id", sess.id, "domain", domain) continue case res.ClientID != "" && res.ClientSecret != "": - s.finishSuccess(ctx, sess, res) + s.finishSuccess(ctx, sess, res, region) return case res.Err != nil: reason := RegistrationReasonProtocol @@ -473,8 +482,11 @@ func (s *RegistrationService) runPolling(sess *registrationSession) { // finishSuccess runs the post-poll finalization: bot info lookup + // installation insert + installer binding, all in a single DB // transaction. -func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrationSession, res *PollResult) { - creds := InstallationCredentials{AppID: res.ClientID, AppSecret: res.ClientSecret} +func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrationSession, res *PollResult, region Region) { + // Carry the detected region onto the credentials so the GetBotInfo + // call below hits the right open-platform host: a Lark-international + // install must reach open.larksuite.com, not the Feishu default. + creds := InstallationCredentials{AppID: res.ClientID, AppSecret: res.ClientSecret, Region: region} info, err := s.api.GetBotInfo(ctx, creds) if err != nil { s.cfg.Logger.Warn("lark registration: bot info failed", @@ -518,6 +530,7 @@ func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrat BotOpenID: string(info.OpenID), BotUnionID: textOrNull(info.UnionID), InstallerUserID: sess.initiatorID, + Region: string(region), }) if err != nil { s.cfg.Logger.Warn("lark registration: upsert installation", diff --git a/server/internal/integrations/lark/types.go b/server/internal/integrations/lark/types.go index 292f7c548..3c8bfa6b1 100644 --- a/server/internal/integrations/lark/types.go +++ b/server/internal/integrations/lark/types.go @@ -35,6 +35,51 @@ const ( InstallationRevoked InstallationStatus = "revoked" ) +// Region identifies which Lark open-platform cloud an installation lives +// on. Feishu (mainland China, open.feishu.cn / accounts.feishu.cn) and +// Lark (international, open.larksuite.com / accounts.larksuite.com) are +// separate clouds with distinct hosts; a single Multica deployment serves +// both by resolving the host per installation from this value rather than +// from a deployment-wide env var. Mirrors the lark_installation.region +// CHECK constraint (migration 116) — keep the two in lockstep. +type Region string + +const ( + RegionFeishu Region = "feishu" + RegionLark Region = "lark" +) + +// larkInternationalOpenBaseURL is the open-platform host for the Lark +// international cloud. The Feishu (mainland) counterpart is +// defaultLarkBaseURL ("https://open.feishu.cn"), defined in http_client.go; +// it doubles as the WS long-conn bootstrap host (the /callback/ws/endpoint +// POST runs against the same open-platform host). +const larkInternationalOpenBaseURL = "https://open.larksuite.com" + +// OpenPlatformBaseURL maps a region to its open-platform host — the base +// URL for both the REST API (http_client.go) and the WebSocket +// /callback/ws/endpoint bootstrap (ws_endpoint.go). An unset or unknown +// region falls back to Feishu (mainland), which is the default every +// pre-region installation row carries. +func (r Region) OpenPlatformBaseURL() string { + if r == RegionLark { + return larkInternationalOpenBaseURL + } + return defaultLarkBaseURL +} + +// RegionOrDefault normalizes a stored region string (originating from the +// lark_installation.region column) to a Region, defaulting to Feishu for +// empty or unrecognized values so a malformed row never resolves to an +// empty host (or a CHECK-violating write). Exported because the router's +// WS credentials provider (package main) hydrates creds from the raw row. +func RegionOrDefault(s string) Region { + if Region(s) == RegionLark { + return RegionLark + } + return RegionFeishu +} + // DropReason enumerates the categories the inbound pipeline writes // into lark_inbound_audit.drop_reason. The DB column is open TEXT so // new reasons can be added without a migration; callers should reuse diff --git a/server/internal/integrations/lark/union_id_backfill.go b/server/internal/integrations/lark/union_id_backfill.go index 00641a9b8..f41dbe275 100644 --- a/server/internal/integrations/lark/union_id_backfill.go +++ b/server/internal/integrations/lark/union_id_backfill.go @@ -81,6 +81,7 @@ func BackfillBotUnionIDs( AppID: row.AppID, AppSecret: secret, TenantKey: row.TenantKey.String, + Region: RegionOrDefault(row.Region), }) cancel() if err != nil { diff --git a/server/internal/integrations/lark/ws_endpoint.go b/server/internal/integrations/lark/ws_endpoint.go index 166730017..e48e1a3a4 100644 --- a/server/internal/integrations/lark/ws_endpoint.go +++ b/server/internal/integrations/lark/ws_endpoint.go @@ -15,13 +15,14 @@ import ( "time" ) -// defaultLarkCallbackBaseURL is the bootstrap host for the long-conn -// `/callback/ws/endpoint` request. Note this is `open.feishu.cn` -// (mainland) regardless of where the WS itself ends up — Lark returns -// the wss URL in the response body. Operators on the international -// tenant override via MULTICA_LARK_CALLBACK_BASE_URL to -// `https://open.larksuite.com`. -const defaultLarkCallbackBaseURL = "https://open.feishu.cn" +// The bootstrap host for the long-conn `/callback/ws/endpoint` request +// is the installation's open-platform host — open.feishu.cn for Feishu +// (mainland), open.larksuite.com for Lark (international) — resolved per +// call from InstallationCredentials.Region via Region.OpenPlatformBaseURL +// (Lark returns the actual wss URL in the response body, so only the +// bootstrap POST host has to be region-aware). A deployment-wide +// MULTICA_LARK_CALLBACK_BASE_URL still overrides every installation when +// set (staging / mock). // HTTPConnectionTokenFetcher is the production EndpointFetcher. It // exchanges per-installation app credentials for a short-lived @@ -51,9 +52,11 @@ type HTTPConnectionTokenFetcher struct { cfg HTTPConnectionTokenConfig } -// HTTPConnectionTokenConfig wires the fetcher's dependencies. BaseURL -// defaults to defaultLarkCallbackBaseURL; tests substitute an -// httptest.Server URL. +// HTTPConnectionTokenConfig wires the fetcher's dependencies. BaseURL is +// an optional deployment-wide override; when empty (the production +// default) Endpoint() resolves the bootstrap host per installation from +// the region. Tests substitute an httptest.Server URL to force all +// regions to the fake server. type HTTPConnectionTokenConfig struct { BaseURL string HTTPClient *http.Client @@ -62,9 +65,12 @@ type HTTPConnectionTokenConfig struct { } func (c HTTPConnectionTokenConfig) withDefaults() HTTPConnectionTokenConfig { - if c.BaseURL == "" { - c.BaseURL = defaultLarkCallbackBaseURL - } + // BaseURL is intentionally NOT defaulted here. Empty means "no + // deployment-wide override" — Endpoint() then resolves the bootstrap + // host per installation from InstallationCredentials.Region, so one + // fetcher serves both Feishu and Lark. A non-empty BaseURL + // (MULTICA_LARK_CALLBACK_BASE_URL, or an httptest URL in tests) + // forces every installation to that host. c.BaseURL = strings.TrimRight(c.BaseURL, "/") if c.HTTPClient == nil { c.HTTPClient = &http.Client{Timeout: defaultRequestTimeout} @@ -119,7 +125,14 @@ func (f *HTTPConnectionTokenFetcher) Endpoint(ctx context.Context, creds Install if err != nil { return WSEndpoint{}, fmt.Errorf("marshal body: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.BaseURL+"/callback/ws/endpoint", bytes.NewReader(raw)) + // Resolve the bootstrap host per call: an explicit cfg.BaseURL + // override wins (env / httptest), otherwise the installation's region + // picks Feishu vs Lark so one fetcher serves both clouds. + base := f.cfg.BaseURL + if base == "" { + base = creds.Region.OpenPlatformBaseURL() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/callback/ws/endpoint", bytes.NewReader(raw)) if err != nil { return WSEndpoint{}, fmt.Errorf("new request: %w", err) } diff --git a/server/migrations/116_lark_installation_region.down.sql b/server/migrations/116_lark_installation_region.down.sql new file mode 100644 index 000000000..910312b27 --- /dev/null +++ b/server/migrations/116_lark_installation_region.down.sql @@ -0,0 +1 @@ +ALTER TABLE lark_installation DROP COLUMN region; diff --git a/server/migrations/116_lark_installation_region.up.sql b/server/migrations/116_lark_installation_region.up.sql new file mode 100644 index 000000000..f1b126cc2 --- /dev/null +++ b/server/migrations/116_lark_installation_region.up.sql @@ -0,0 +1,22 @@ +-- Add a per-installation `region` so one Multica deployment can serve +-- BOTH mainland Feishu (open.feishu.cn / accounts.feishu.cn) and Lark +-- international (open.larksuite.com / accounts.larksuite.com) at the same +-- time. Before this column the open-platform host was a single +-- deployment-wide value (the MULTICA_LARK_HTTP_BASE_URL / +-- MULTICA_LARK_CALLBACK_BASE_URL env knobs, defaulting to open.feishu.cn), +-- so a given deployment could talk to only one cloud at a time. +-- +-- The device-flow installer already auto-detects the tenant: Lark emits +-- user_info.tenant_brand="lark" mid-poll and RegistrationService swaps the +-- accounts host to accounts.larksuite.com. finishSuccess now persists that +-- detected region here, and every outbound REST + WebSocket call resolves +-- its open-platform host from this column via InstallationCredentials.Region. +-- +-- NOT NULL DEFAULT 'feishu' is the safe backfill: every installation that +-- exists today was created against mainland Feishu (the only host the old +-- code reached without an env override), so 'feishu' is correct for all +-- pre-migration rows. The CHECK mirrors the lark.Region enum in +-- server/internal/integrations/lark/types.go — keep the two in lockstep. +ALTER TABLE lark_installation + ADD COLUMN region TEXT NOT NULL DEFAULT 'feishu' + CHECK (region IN ('feishu', 'lark')); diff --git a/server/pkg/db/generated/lark.sql.go b/server/pkg/db/generated/lark.sql.go index 0581a634a..1791c80a6 100644 --- a/server/pkg/db/generated/lark.sql.go +++ b/server/pkg/db/generated/lark.sql.go @@ -23,7 +23,7 @@ WHERE id = $3 OR ws_lease_expires_at < now() OR ws_lease_token = $1 ) -RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at +RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region ` type AcquireLarkWSLeaseParams struct { @@ -48,7 +48,6 @@ func (q *Queries) AcquireLarkWSLease(ctx context.Context, arg AcquireLarkWSLease &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -56,6 +55,8 @@ func (q *Queries) AcquireLarkWSLease(ctx context.Context, arg AcquireLarkWSLease &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ) return i, err } @@ -250,7 +251,7 @@ INSERT INTO lark_installation ( ) VALUES ( $1, $2, $3, $4, $7, $5, $8, $6 ) -RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at +RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region ` type CreateLarkInstallationParams struct { @@ -302,7 +303,6 @@ func (q *Queries) CreateLarkInstallation(ctx context.Context, arg CreateLarkInst &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -310,6 +310,8 @@ func (q *Queries) CreateLarkInstallation(ctx context.Context, arg CreateLarkInst &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ) return i, err } @@ -483,7 +485,7 @@ func (q *Queries) GetLarkChatSessionBindingBySession(ctx context.Context, chatSe } const getLarkInstallation = `-- name: GetLarkInstallation :one -SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at FROM lark_installation WHERE id = $1 +SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region FROM lark_installation WHERE id = $1 ` func (q *Queries) GetLarkInstallation(ctx context.Context, id pgtype.UUID) (LarkInstallation, error) { @@ -497,7 +499,6 @@ func (q *Queries) GetLarkInstallation(ctx context.Context, id pgtype.UUID) (Lark &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -505,12 +506,14 @@ func (q *Queries) GetLarkInstallation(ctx context.Context, id pgtype.UUID) (Lark &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ) return i, err } const getLarkInstallationByAgent = `-- name: GetLarkInstallationByAgent :one -SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at FROM lark_installation +SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region FROM lark_installation WHERE workspace_id = $1 AND agent_id = $2 ` @@ -530,7 +533,6 @@ func (q *Queries) GetLarkInstallationByAgent(ctx context.Context, arg GetLarkIns &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -538,12 +540,14 @@ func (q *Queries) GetLarkInstallationByAgent(ctx context.Context, arg GetLarkIns &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ) return i, err } const getLarkInstallationByAppID = `-- name: GetLarkInstallationByAppID :one -SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at FROM lark_installation WHERE app_id = $1 +SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region FROM lark_installation WHERE app_id = $1 ` // Used by the OAuth callback to detect re-install vs first-install, @@ -560,7 +564,6 @@ func (q *Queries) GetLarkInstallationByAppID(ctx context.Context, appID string) &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -568,12 +571,14 @@ func (q *Queries) GetLarkInstallationByAppID(ctx context.Context, appID string) &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ) return i, err } const getLarkInstallationInWorkspace = `-- name: GetLarkInstallationInWorkspace :one -SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at FROM lark_installation +SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region FROM lark_installation WHERE id = $1 AND workspace_id = $2 ` @@ -593,7 +598,6 @@ func (q *Queries) GetLarkInstallationInWorkspace(ctx context.Context, arg GetLar &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -601,6 +605,8 @@ func (q *Queries) GetLarkInstallationInWorkspace(ctx context.Context, arg GetLar &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ) return i, err } @@ -659,7 +665,7 @@ func (q *Queries) GetLarkUserBindingByOpenID(ctx context.Context, arg GetLarkUse } const listActiveLarkInstallations = `-- name: ListActiveLarkInstallations :many -SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at FROM lark_installation +SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region FROM lark_installation WHERE status = 'active' ORDER BY created_at ASC ` @@ -684,7 +690,6 @@ func (q *Queries) ListActiveLarkInstallations(ctx context.Context) ([]LarkInstal &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -692,6 +697,8 @@ func (q *Queries) ListActiveLarkInstallations(ctx context.Context) ([]LarkInstal &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ); err != nil { return nil, err } @@ -747,7 +754,7 @@ func (q *Queries) ListLarkInboundAuditByInstallation(ctx context.Context, arg Li } const listLarkInstallationsByWorkspace = `-- name: ListLarkInstallationsByWorkspace :many -SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at FROM lark_installation +SELECT id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region FROM lark_installation WHERE workspace_id = $1 ORDER BY created_at ASC ` @@ -769,7 +776,6 @@ func (q *Queries) ListLarkInstallationsByWorkspace(ctx context.Context, workspac &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -777,6 +783,8 @@ func (q *Queries) ListLarkInstallationsByWorkspace(ctx context.Context, workspac &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ); err != nil { return nil, err } @@ -983,22 +991,6 @@ func (q *Queries) ReleaseLarkWSLease(ctx context.Context, arg ReleaseLarkWSLease return err } -const setLarkInstallationStatus = `-- name: SetLarkInstallationStatus :exec -UPDATE lark_installation -SET status = $2, updated_at = now() -WHERE id = $1 -` - -type SetLarkInstallationStatusParams struct { - ID pgtype.UUID `json:"id"` - Status string `json:"status"` -} - -func (q *Queries) SetLarkInstallationStatus(ctx context.Context, arg SetLarkInstallationStatusParams) error { - _, err := q.db.Exec(ctx, setLarkInstallationStatus, arg.ID, arg.Status) - return err -} - const setLarkInstallationBotUnionID = `-- name: SetLarkInstallationBotUnionID :exec UPDATE lark_installation SET bot_union_id = $2, @@ -1022,6 +1014,22 @@ func (q *Queries) SetLarkInstallationBotUnionID(ctx context.Context, arg SetLark return err } +const setLarkInstallationStatus = `-- name: SetLarkInstallationStatus :exec +UPDATE lark_installation +SET status = $2, updated_at = now() +WHERE id = $1 +` + +type SetLarkInstallationStatusParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) SetLarkInstallationStatus(ctx context.Context, arg SetLarkInstallationStatusParams) error { + _, err := q.db.Exec(ctx, setLarkInstallationStatus, arg.ID, arg.Status) + return err +} + const updateLarkOutboundCardStatus = `-- name: UpdateLarkOutboundCardStatus :exec UPDATE lark_outbound_card_message SET status = $2, @@ -1042,9 +1050,9 @@ func (q *Queries) UpdateLarkOutboundCardStatus(ctx context.Context, arg UpdateLa const upsertLarkInstallation = `-- name: UpsertLarkInstallation :one INSERT INTO lark_installation ( workspace_id, agent_id, app_id, app_secret_encrypted, - tenant_key, bot_open_id, bot_union_id, installer_user_id + tenant_key, bot_open_id, bot_union_id, installer_user_id, region ) VALUES ( - $1, $2, $3, $4, $7, $5, $8, $6 + $1, $2, $3, $4, $7, $5, $8, $6, $9 ) ON CONFLICT (workspace_id, agent_id) DO UPDATE SET app_id = EXCLUDED.app_id, @@ -1053,10 +1061,11 @@ ON CONFLICT (workspace_id, agent_id) DO UPDATE SET bot_open_id = EXCLUDED.bot_open_id, bot_union_id = EXCLUDED.bot_union_id, installer_user_id = EXCLUDED.installer_user_id, + region = EXCLUDED.region, status = 'active', installed_at = now(), updated_at = now() -RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at +RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region ` type UpsertLarkInstallationParams struct { @@ -1068,6 +1077,7 @@ type UpsertLarkInstallationParams struct { InstallerUserID pgtype.UUID `json:"installer_user_id"` TenantKey pgtype.Text `json:"tenant_key"` BotUnionID pgtype.Text `json:"bot_union_id"` + Region string `json:"region"` } // Re-install path: a user who already bound this agent to Lark scans @@ -1086,6 +1096,7 @@ func (q *Queries) UpsertLarkInstallation(ctx context.Context, arg UpsertLarkInst arg.InstallerUserID, arg.TenantKey, arg.BotUnionID, + arg.Region, ) var i LarkInstallation err := row.Scan( @@ -1096,7 +1107,6 @@ func (q *Queries) UpsertLarkInstallation(ctx context.Context, arg UpsertLarkInst &i.AppSecretEncrypted, &i.TenantKey, &i.BotOpenID, - &i.BotUnionID, &i.InstallerUserID, &i.Status, &i.WsLeaseToken, @@ -1104,6 +1114,8 @@ func (q *Queries) UpsertLarkInstallation(ctx context.Context, arg UpsertLarkInst &i.InstalledAt, &i.CreatedAt, &i.UpdatedAt, + &i.BotUnionID, + &i.Region, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index d213c531f..14eb2625c 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -453,7 +453,6 @@ type LarkInstallation struct { AppSecretEncrypted []byte `json:"app_secret_encrypted"` TenantKey pgtype.Text `json:"tenant_key"` BotOpenID string `json:"bot_open_id"` - BotUnionID pgtype.Text `json:"bot_union_id"` InstallerUserID pgtype.UUID `json:"installer_user_id"` Status string `json:"status"` WsLeaseToken pgtype.Text `json:"ws_lease_token"` @@ -461,6 +460,8 @@ type LarkInstallation struct { InstalledAt pgtype.Timestamptz `json:"installed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + BotUnionID pgtype.Text `json:"bot_union_id"` + Region string `json:"region"` } type LarkOutboundCardMessage struct { diff --git a/server/pkg/db/queries/lark.sql b/server/pkg/db/queries/lark.sql index 144871ccc..e656d19eb 100644 --- a/server/pkg/db/queries/lark.sql +++ b/server/pkg/db/queries/lark.sql @@ -36,9 +36,9 @@ RETURNING *; -- lifecycle. INSERT INTO lark_installation ( workspace_id, agent_id, app_id, app_secret_encrypted, - tenant_key, bot_open_id, bot_union_id, installer_user_id + tenant_key, bot_open_id, bot_union_id, installer_user_id, region ) VALUES ( - $1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6 + $1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6, sqlc.arg('region') ) ON CONFLICT (workspace_id, agent_id) DO UPDATE SET app_id = EXCLUDED.app_id, @@ -47,6 +47,7 @@ ON CONFLICT (workspace_id, agent_id) DO UPDATE SET bot_open_id = EXCLUDED.bot_open_id, bot_union_id = EXCLUDED.bot_union_id, installer_user_id = EXCLUDED.installer_user_id, + region = EXCLUDED.region, status = 'active', installed_at = now(), updated_at = now()