feat(lark): serve Feishu and Lark from one deployment, per installation

The Lark integration was locked to a single open-platform host chosen
deployment-wide (MULTICA_LARK_HTTP_BASE_URL / _CALLBACK_BASE_URL,
defaulting to open.feishu.cn), so one deployment could talk to only the
mainland Feishu cloud OR Lark international — never both. Teams on the
other tenant could not use the integration at all.

Make the host per-installation. The device-flow installer already
auto-detects the tenant (Lark emits tenant_brand="lark" mid-poll); we now
persist that as lark_installation.region, carry it on
InstallationCredentials.Region, and resolve the open-platform host per
call (REST + WS bootstrap) from the region. An explicit cfg.BaseURL
(env / httptest) still overrides every region, so existing tests and
staging/proxy setups keep working.

- migration 116: lark_installation.region TEXT NOT NULL DEFAULT 'feishu'
  CHECK (region IN ('feishu','lark')) — existing rows are all mainland.
- lark.Region enum + OpenPlatformBaseURL/RegionOrDefault helpers.
- registration: thread the detected region into finishSuccess so the
  install-time GetBotInfo hits the right cloud AND the row records it.
- every credential-build site (patcher, replier, WS provider, union_id
  backfill) copies region off the installation row.
- region is part of the WS supervisor fingerprint so a re-install that
  switches cloud restarts the connection.
- API: surface region on the installation listing DTO.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-06-05 15:26:41 +08:00
parent 76dbb87762
commit c1b236f9cf
18 changed files with 410 additions and 81 deletions

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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:

View File

@@ -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))),
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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

View File

@@ -81,6 +81,7 @@ func BackfillBotUnionIDs(
AppID: row.AppID,
AppSecret: secret,
TenantKey: row.TenantKey.String,
Region: RegionOrDefault(row.Region),
})
cancel()
if err != nil {

View File

@@ -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)
}

View File

@@ -0,0 +1 @@
ALTER TABLE lark_installation DROP COLUMN region;

View File

@@ -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'));

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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()