diff --git a/.env.example b/.env.example
index c528bd141c..8dc31e93aa 100644
--- a/.env.example
+++ b/.env.example
@@ -215,10 +215,15 @@ GITHUB_APP_PRIVATE_KEY=
# that encrypts each Bot's app secret at rest. Leave empty to disable.
# Generate one with: openssl rand -base64 32
MULTICA_LARK_SECRET_KEY=
-# The two base URLs default to the mainland host (open.feishu.cn). For
-# international Lark tenants, set BOTH to https://open.larksuite.com:
-# HTTP drives outbound Open Platform API calls, CALLBACK drives the
-# inbound long-conn callback bootstrap. See docs/lark-bot-integration.
+# Mainland 飞书 and international Lark are auto-detected per installation
+# (at QR scan) and served side by side — LEAVE THESE EMPTY for normal use.
+# They are optional deployment-wide overrides that force EVERY installation
+# onto one host (a proxy, a mock for tests, or a single-cloud staging
+# setup); HTTP drives outbound Open Platform API calls, CALLBACK the inbound
+# long-conn bootstrap. NOTE: if you previously ran international Lark by
+# setting these to https://open.larksuite.com, the server relabels your
+# existing installs to region=lark on first boot after upgrade, so you can
+# clear these afterwards. See docs/lark-bot-integration.
MULTICA_LARK_HTTP_BASE_URL=
MULTICA_LARK_CALLBACK_BASE_URL=
diff --git a/apps/docs/content/docs/lark-bot-integration.ja.mdx b/apps/docs/content/docs/lark-bot-integration.ja.mdx
index 06da20bd11..86497d693b 100644
--- a/apps/docs/content/docs/lark-bot-integration.ja.mdx
+++ b/apps/docs/content/docs/lark-bot-integration.ja.mdx
@@ -80,11 +80,11 @@ Multica Cloud では連携はすでに利用可能です——このセクショ
2. API を再起動します。キーを設定するまで、**設定 → 連携** には「Lark integration not enabled」という通知が表示され、**Lark に紐づける** のエントリポイントは非表示のままになります。
-**国際版テナント。** 連携はデフォルトで中国大陸のホスト(`open.feishu.cn`)を使います。組織が Lark の国際版テナントにある場合は、トランスポートをそちらに向けてください。
+**Feishu と Lark 国際版の両対応。** 各 Bot がどのクラウド(中国大陸の Feishu = `open.feishu.cn`、国際版 Lark = `open.larksuite.com`)に属するかは、QR コードをスキャンした時点で自動的に判定され、そのインストールに保存されて、その Bot へのすべての呼び出しに使われます。1 つのデプロイで両方を同時に提供できるため、どちらのテナントのチームも追加設定なしでバインドできます。
-```dotenv
-MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
-```
+`MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL` は、デプロイ全体を上書きする任意のオプション(プロキシやモック用)としてのみ残っています。通常運用では未設定のままにして、各インストールがそれぞれのクラウドに到達するようにしてください。
+
+**単一クラウド構成からのアップグレード?** これらを `https://open.larksuite.com` に設定して国際版 Lark を運用していた場合、アップグレード後の初回起動時にサーバーが既存のインストールを Lark リージョンへ付け替えるので、その後はこの上書きを外せます。中国大陸の Feishu デプロイでは操作は不要です。
## 次に
diff --git a/apps/docs/content/docs/lark-bot-integration.ko.mdx b/apps/docs/content/docs/lark-bot-integration.ko.mdx
index da9ae97bcb..f5e12a274b 100644
--- a/apps/docs/content/docs/lark-bot-integration.ko.mdx
+++ b/apps/docs/content/docs/lark-bot-integration.ko.mdx
@@ -80,11 +80,11 @@ Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은
2. API를 재시작하세요. 키가 설정되기 전까지 **설정 → Integrations**에는 "Lark integration not enabled" 안내가 표시되고, **Bind to Lark** 진입점은 숨겨진 채로 유지됩니다.
-**국제판 테넌트.** 연동은 기본적으로 중국 본토 호스트(`open.feishu.cn`)를 사용합니다. 당신의 조직이 Lark 국제판 테넌트에 있다면, 전송 계층을 그쪽으로 가리키게 하세요.
+**Feishu와 Lark 국제판을 동시에 지원.** 각 Bot이 어느 클라우드(중국 본토 Feishu = `open.feishu.cn`, 국제판 Lark = `open.larksuite.com`)에 속하는지는 QR 코드를 스캔할 때 자동으로 감지되어 해당 설치에 저장되고, 그 Bot에 대한 모든 호출에 사용됩니다. 하나의 배포로 둘을 동시에 제공하므로, 어느 테넌트의 팀이든 추가 설정 없이 바인딩할 수 있습니다.
-```dotenv
-MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
-```
+`MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL`는 배포 전체를 덮어쓰는 선택적 오버라이드(프록시나 mock용)로만 남아 있습니다. 일반 운영에서는 설정하지 않은 채로 두어 각 설치가 자기 클라우드에 도달하도록 하세요.
+
+**단일 클라우드 구성에서 업그레이드하나요?** 이 변수들을 `https://open.larksuite.com`으로 설정해 국제판 Lark를 운영했다면, 업그레이드 후 첫 부팅 시 서버가 기존 설치를 Lark 리전으로 다시 표시하므로 이후에는 오버라이드를 지울 수 있습니다. 중국 본토 Feishu 배포에서는 별도 작업이 필요 없습니다.
## 다음
diff --git a/apps/docs/content/docs/lark-bot-integration.mdx b/apps/docs/content/docs/lark-bot-integration.mdx
index 2fd881c282..87b6e5d46b 100644
--- a/apps/docs/content/docs/lark-bot-integration.mdx
+++ b/apps/docs/content/docs/lark-bot-integration.mdx
@@ -80,11 +80,11 @@ For self-host, Lark is **off until you set an at-rest encryption key**. The key
2. Restart the API. Until the key is set, **Settings → Integrations** shows a "Lark integration not enabled" notice and the **Bind to Lark** entry points stay hidden.
-**International tenants.** The integration defaults to the mainland host (`open.feishu.cn`). If your organization is on Lark's international tenant, point the transport at it:
+**Feishu and Lark international, side by side.** The cloud each Bot belongs to — mainland Feishu (`open.feishu.cn`) or Lark international (`open.larksuite.com`) — is detected automatically when you scan the QR, stored on the installation, and used for every call to that Bot. A single deployment serves both at once, so teams on either tenant can bind without any extra configuration.
-```dotenv
-MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
-```
+The `MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL` env vars remain only as an optional deployment-wide override (a proxy or a mock); leave them unset for normal operation so each installation keeps reaching its own cloud.
+
+**Upgrading from a single-cloud setup?** If you ran an international-Lark deployment by setting those vars to `https://open.larksuite.com`, the server relabels your existing installations to the Lark region on first boot after upgrade — you can then clear the override. Mainland deployments need no action.
## Next
diff --git a/apps/docs/content/docs/lark-bot-integration.zh.mdx b/apps/docs/content/docs/lark-bot-integration.zh.mdx
index 0427efe77e..82d1e60dac 100644
--- a/apps/docs/content/docs/lark-bot-integration.zh.mdx
+++ b/apps/docs/content/docs/lark-bot-integration.zh.mdx
@@ -80,11 +80,11 @@ import { Callout } from "fumadocs-ui/components/callout";
2. 重启 API。在密钥设置好之前,**设置 → 集成** 会显示「未启用飞书集成」提示,**绑定到飞书** 入口也会保持隐藏。
-**国际版租户。** 集成默认走中国大陆主机(`open.feishu.cn`)。如果你的组织在飞书国际版(Lark)租户上,把传输层指过去:
+**同时支持飞书与海外版 Lark。** 每个 Bot 属于哪个云——中国大陆飞书(`open.feishu.cn`)还是海外版 Lark(`open.larksuite.com`)——会在你扫码时自动识别、记录在该安装上,并用于对这个 Bot 的所有调用。同一个部署可以同时服务两者,因此两个租户的团队都能直接绑定,无需任何额外配置。
-```dotenv
-MULTICA_LARK_HTTP_BASE_URL=https://open.larksuite.com
-```
+`MULTICA_LARK_HTTP_BASE_URL` / `MULTICA_LARK_CALLBACK_BASE_URL` 仅作为可选的部署级覆盖项保留(用于代理或 mock);正常运行时请保持不设置,让每个安装各自连到自己的云。
+
+**从单云部署升级?** 如果你之前是把这两个变量设为 `https://open.larksuite.com` 来跑海外版 Lark,升级后服务会在首次启动时自动把存量安装重标记为 Lark region,之后你就可以清掉这个覆盖项。国内飞书部署无需任何操作。
## 下一步
diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml
index 92f829a5a0..db089cce1c 100644
--- a/docker-compose.selfhost.yml
+++ b/docker-compose.selfhost.yml
@@ -98,11 +98,14 @@ services:
# "127.0.0.1/32" when running behind a same-host reverse proxy.
MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-}
# Lark / Feishu bot integration. MULTICA_LARK_SECRET_KEY is the
- # opt-in: unset = integration disabled. The two base-URL knobs
- # default to the mainland host (open.feishu.cn); international Lark
- # tenants must point BOTH at https://open.larksuite.com — one drives
- # outbound Open Platform API calls, the other the inbound long-conn
- # callback bootstrap. See docs/lark-bot-integration.
+ # opt-in: unset = integration disabled. Mainland 飞书 and international
+ # Lark are auto-detected per installation and served side by side, so
+ # the two base-URL knobs should normally stay EMPTY. They are optional
+ # deployment-wide overrides that force every installation onto one host
+ # (proxy / mock / single-cloud staging). Upgrading from a setup that
+ # used https://open.larksuite.com here? The server relabels existing
+ # installs to region=lark on first boot, then you can clear them.
+ # See docs/lark-bot-integration.
MULTICA_LARK_SECRET_KEY: ${MULTICA_LARK_SECRET_KEY:-}
MULTICA_LARK_HTTP_BASE_URL: ${MULTICA_LARK_HTTP_BASE_URL:-}
MULTICA_LARK_CALLBACK_BASE_URL: ${MULTICA_LARK_CALLBACK_BASE_URL:-}
diff --git a/packages/core/types/lark.ts b/packages/core/types/lark.ts
index 4798adcc6f..86245e5581 100644
--- a/packages/core/types/lark.ts
+++ b/packages/core/types/lark.ts
@@ -13,6 +13,12 @@ export interface LarkInstallation {
bot_open_id: string;
installer_user_id: string;
status: "active" | "revoked" | string;
+ /** Which Lark cloud the bot lives on: "feishu" (mainland) or "lark"
+ * (international). Auto-detected at install time. Optional so an older
+ * desktop build parsing a newer server — or a newer build hitting a
+ * server that predates the field — defaults to Feishu in the UI
+ * (see CLAUDE.md → API Response Compatibility). */
+ region?: "feishu" | "lark" | string;
installed_at: string;
created_at: string;
updated_at: string;
diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json
index 8d1f9681a0..4695c8b88f 100644
--- a/packages/views/locales/en/settings.json
+++ b/packages/views/locales/en/settings.json
@@ -233,6 +233,8 @@
"empty_description_cta": "Bind to Lark",
"empty_description_suffix": "to install a Bot for it.",
"revoked_badge": "revoked",
+ "region_feishu": "Feishu",
+ "region_lark": "Lark",
"installed_at_label": "Installed {{when}}",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting…",
diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json
index 7d7b34d2d5..7ab4b02ebc 100644
--- a/packages/views/locales/ja/settings.json
+++ b/packages/views/locales/ja/settings.json
@@ -233,6 +233,8 @@
"empty_description_cta": "Lark に接続",
"empty_description_suffix": "をクリックすると、対応するボットを設置できます。",
"revoked_badge": "取り消し済み",
+ "region_feishu": "Feishu",
+ "region_lark": "Lark",
"installed_at_label": "{{when}} に設置",
"disconnect": "切断",
"disconnecting": "切断しています…",
diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json
index a190e0a7f7..acda5712e6 100644
--- a/packages/views/locales/ko/settings.json
+++ b/packages/views/locales/ko/settings.json
@@ -310,6 +310,8 @@
"empty_description_cta": "Lark에 연결",
"empty_description_suffix": "을(를) 클릭해 봇을 설치하세요.",
"revoked_badge": "취소됨",
+ "region_feishu": "Feishu",
+ "region_lark": "Lark",
"installed_at_label": "{{when}} 설치됨",
"disconnect": "연결 해제",
"disconnecting": "연결을 해제하는 중…",
diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json
index 8e63ba9ede..e3489568f0 100644
--- a/packages/views/locales/zh-Hans/settings.json
+++ b/packages/views/locales/zh-Hans/settings.json
@@ -233,6 +233,8 @@
"empty_description_cta": "绑定到飞书",
"empty_description_suffix": "即可为它安装 Bot。",
"revoked_badge": "已撤销",
+ "region_feishu": "飞书",
+ "region_lark": "Lark",
"installed_at_label": "安装于 {{when}}",
"disconnect": "断开连接",
"disconnecting": "正在断开…",
diff --git a/packages/views/settings/components/lark-tab.test.tsx b/packages/views/settings/components/lark-tab.test.tsx
index cb6e8daa5b..3278d90d22 100644
--- a/packages/views/settings/components/lark-tab.test.tsx
+++ b/packages/views/settings/components/lark-tab.test.tsx
@@ -251,6 +251,32 @@ describe("LarkAgentBindButton (CTA gate)", () => {
expect(link.rel).toContain("noopener");
});
+ it("points the Manage link at open.larksuite.com for a Lark-international (region=lark) installation", () => {
+ // Dual-region: a bot installed against the Lark international cloud
+ // must manage at open.larksuite.com, not the Feishu default. The
+ // region rides on the listings response, auto-detected at install.
+ installationsRef.current.installations = [
+ {
+ id: "inst-lark",
+ workspace_id: "ws-1",
+ agent_id: "agent-1",
+ app_id: "cli_lark_app",
+ bot_open_id: "ou_lark_bot",
+ installer_user_id: "user-1",
+ status: "active",
+ region: "lark",
+ installed_at: "2026-06-03T00:00:00Z",
+ created_at: "2026-06-03T00:00:00Z",
+ updated_at: "2026-06-03T00:00:00Z",
+ },
+ ];
+ render(, {
+ wrapper: I18nWrapper,
+ });
+ const link = screen.getByRole("link", { name: /Manage in Lark/i }) as HTMLAnchorElement;
+ expect(link.href).toBe("https://open.larksuite.com/app/cli_lark_app");
+ });
+
it("still shows the bind CTA when an installation exists for a DIFFERENT agent (per-agent scoping)", () => {
installationsRef.current.installations = [
{
diff --git a/packages/views/settings/components/lark-tab.tsx b/packages/views/settings/components/lark-tab.tsx
index 882d3c4e5a..5e957d113b 100644
--- a/packages/views/settings/components/lark-tab.tsx
+++ b/packages/views/settings/components/lark-tab.tsx
@@ -232,6 +232,11 @@ function InstallationRow({
{agentName}
+
+ {installation.region === "lark"
+ ? t(($) => $.lark.region_lark)
+ : t(($) => $.lark.region_feishu)}
+
{!isActive && (
{t(($) => $.lark.revoked_badge)}
@@ -359,12 +364,17 @@ export function LarkAgentBindButton({
// a new tab so the user can manage scopes / display name / additional
// permissions without re-scanning the QR.
//
-// The dev console URL host follows the same default as the backend's
-// LARK_BASE_URL (open.feishu.cn for mainland Lark). Operators on the
-// Lark international tenant currently see the wrong host; future-
-// proofing requires the backend to surface a per-installation
-// `dev_console_url` on the listings response. Tracked separately.
-const LARK_DEV_CONSOLE_HOST = "https://open.feishu.cn";
+// The dev-console host depends on which Lark cloud the bot lives on:
+// Feishu (mainland) bots are managed at open.feishu.cn, Lark
+// (international) bots at open.larksuite.com. The region is auto-detected
+// at install time and surfaced per installation on the listings
+// response; an older server that omits `region` defaults to Feishu
+// (API-compat — see CLAUDE.md).
+function larkDevConsoleHost(region?: string): string {
+ return region === "lark"
+ ? "https://open.larksuite.com"
+ : "https://open.feishu.cn";
+}
function LarkAgentBotConnectedBadge({
installation,
@@ -374,7 +384,7 @@ function LarkAgentBotConnectedBadge({
className?: string;
}) {
const { t } = useT("settings");
- const manageHref = `${LARK_DEV_CONSOLE_HOST}/app/${encodeURIComponent(installation.app_id)}`;
+ const manageHref = `${larkDevConsoleHost(installation.region)}/app/${encodeURIComponent(installation.app_id)}`;
return (
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index 92d15cb228..9c040ca363 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -209,10 +209,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// real Lark traffic can point MULTICA_LARK_HTTP_BASE_URL
// at a mock server.
//
- // MULTICA_LARK_HTTP_BASE_URL overrides the default
- // open.feishu.cn host (set to https://open.larksuite.com
- // for the Lark international tenant, or to a mock for
- // integration tests).
+ // MULTICA_LARK_HTTP_BASE_URL is an OPTIONAL deployment-wide
+ // override. Normal operation leaves it empty: each call then
+ // resolves its open-platform host from the installation's
+ // region (open.feishu.cn vs open.larksuite.com), so one
+ // deployment serves both clouds. Set it only to force every
+ // installation onto one host — a proxy, a mock for tests, or
+ // a single-cloud staging setup.
larkClient := lark.NewHTTPAPIClient(lark.HTTPClientConfig{
BaseURL: strings.TrimSpace(os.Getenv("MULTICA_LARK_HTTP_BASE_URL")),
Logger: slog.Default(),
@@ -295,6 +298,19 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
// on a fresh deployment and exit. MUL-2671.
go lark.BackfillBotUnionIDs(context.Background(), queries, larkClient, installSvc, slog.Default())
+ // Upgrade repair for deployments that ran the whole
+ // integration against Lark international via the deployment-
+ // wide base-URL override before per-installation region
+ // existed: migration 116 backfilled their rows to 'feishu',
+ // so relabel them to 'lark' (their true cloud) before the
+ // operator clears the override. No-op on mainland / fresh
+ // deployments. Off the hot startup path like the union_id
+ // backfill. MUL-3083.
+ go lark.BackfillRegionFromLegacyOverride(context.Background(), queries,
+ strings.TrimSpace(os.Getenv("MULTICA_LARK_HTTP_BASE_URL")),
+ strings.TrimSpace(os.Getenv("MULTICA_LARK_CALLBACK_BASE_URL")),
+ slog.Default())
+
// Device-flow registration service: end-to-end install
// pipeline that talks to accounts.feishu.cn (RFC 8628)
// for the QR-scan handshake and then commits the
@@ -980,6 +996,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 d6810d565f..0902b89804 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 909091ac8f..c11e401bee 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 6a3ab1da51..76af73e7fc 100644
--- a/server/internal/integrations/lark/http_client.go
+++ b/server/internal/integrations/lark/http_client.go
@@ -33,10 +33,13 @@ import (
// never present a token that's about to lapse mid-flight.
const (
- // defaultLarkBaseURL is the production 飞书 (mainland) open-platform
- // host. Operators on the Lark international tenant set
- // MULTICA_LARK_HTTP_BASE_URL to https://open.larksuite.com; tests
- // substitute an httptest.Server URL.
+ // defaultLarkBaseURL is the mainland 飞书 open-platform host. It is the
+ // fallback host for an installation whose region is feishu (or unset);
+ // Region.OpenPlatformBaseURL maps region=lark to open.larksuite.com.
+ // Operators do NOT set MULTICA_LARK_HTTP_BASE_URL to pick a cloud
+ // anymore — the per-installation region does that automatically. The
+ // env var remains only as a deployment-wide override (proxy / mock /
+ // single-cloud staging); tests substitute an httptest.Server URL.
defaultLarkBaseURL = "https://open.feishu.cn"
// tokenSafetyMargin is subtracted from Lark's `expire` so we
@@ -60,9 +63,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 +88,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}
@@ -109,7 +120,14 @@ func NewHTTPAPIClient(cfg HTTPClientConfig) APIClient {
type httpAPIClient struct {
cfg HTTPClientConfig
- mu sync.Mutex
+ mu sync.Mutex
+ // tokens caches tenant_access_token keyed by app_id only — NOT by
+ // (app_id, region). This is safe because a Lark/飞书 app_id (the
+ // "cli_..." credential) is globally unique across both clouds and an
+ // app exists on exactly one of them, so an app_id never maps to two
+ // regions. The DB enforces the same assumption with UNIQUE(app_id) on
+ // lark_installation. If Lark ever reused an app_id across clouds, both
+ // this cache key and that constraint would need region added.
tokens map[string]*cachedToken
}
@@ -165,7 +183,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 +206,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 +256,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 +307,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 +377,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 +409,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 +452,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 +508,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 +525,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 +568,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 +641,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 +657,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 +675,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 +688,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 63bd500899..5063ab798e 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 3e1def9deb..f4e51c9748 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 d6cea31c32..61fe6d7997 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 f487cbd09b..21eec0e1e2 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_backfill.go b/server/internal/integrations/lark/region_backfill.go
new file mode 100644
index 0000000000..a1096a8987
--- /dev/null
+++ b/server/internal/integrations/lark/region_backfill.go
@@ -0,0 +1,72 @@
+package lark
+
+import (
+ "context"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
+)
+
+// BackfillRegionFromLegacyOverride is the upgrade-repair path for self-host
+// deployments that ran the WHOLE Lark integration against Lark international
+// via the deployment-wide MULTICA_LARK_HTTP_BASE_URL /
+// MULTICA_LARK_CALLBACK_BASE_URL override, before per-installation region
+// existed.
+//
+// Migration 116 backfilled every existing row to 'feishu' (the mainland
+// default). On such a deployment every install is really Lark, so once
+// region drives the host those rows would route to open.feishu.cn and
+// break the moment the operator clears the override (which the new docs
+// invite them to do). When either override host is the Lark international
+// host, we flip the still-default rows to 'lark'.
+//
+// Gating on the override is what makes this safe: the override was
+// deployment-wide, so EVERY pre-existing install on it was Lark — there is
+// no mixed state to misclassify. It is idempotent (after the flip nothing
+// remains at 'feishu'), and new installs already carry the device-flow-
+// detected region, so this only ever touches the legacy rows. Mainland
+// deployments (no override, or override pointing at open.feishu.cn / a
+// mock) never run the UPDATE.
+//
+// Callers should fire this from a goroutine at boot, like
+// BackfillBotUnionIDs, so a slow DB write cannot block listener startup.
+func BackfillRegionFromLegacyOverride(ctx context.Context, queries *db.Queries, httpOverride, callbackOverride string, log *slog.Logger) {
+ if log == nil {
+ log = slog.Default()
+ }
+ if queries == nil {
+ return
+ }
+ if !isLarkInternationalHost(httpOverride) && !isLarkInternationalHost(callbackOverride) {
+ // No override, mainland override, or a mock/staging host: the
+ // migration's 'feishu' default is correct for these rows.
+ return
+ }
+ n, err := queries.BackfillLarkInstallationRegionToLark(ctx)
+ if err != nil {
+ log.Warn("lark region backfill: relabel legacy Lark-international installs failed", "err", err)
+ return
+ }
+ if n > 0 {
+ log.Info("lark region backfill: relabelled legacy Lark-international installs to region=lark",
+ "rows", n)
+ }
+}
+
+// isLarkInternationalHost reports whether a configured base-URL override
+// targets the Lark international open-platform host (open.larksuite.com).
+// It parses the URL and matches the host exactly so a mainland host, an
+// empty value, or a staging/mock URL never triggers the upgrade relabel.
+func isLarkInternationalHost(raw string) bool {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return false
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return false
+ }
+ return strings.EqualFold(u.Host, "open.larksuite.com")
+}
diff --git a/server/internal/integrations/lark/region_test.go b/server/internal/integrations/lark/region_test.go
new file mode 100644
index 0000000000..3af258417f
--- /dev/null
+++ b/server/internal/integrations/lark/region_test.go
@@ -0,0 +1,198 @@
+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)
+ }
+ }
+}
+
+// TestIsLarkInternationalHost gates the upgrade-repair backfill: only a
+// deployment-wide override pointing at open.larksuite.com should relabel
+// legacy installs. Mainland, empty, mock/staging, and scheme-less values
+// must NOT trigger it.
+func TestIsLarkInternationalHost(t *testing.T) {
+ cases := map[string]bool{
+ "https://open.larksuite.com": true,
+ "https://open.larksuite.com/": true,
+ "https://OPEN.LARKSUITE.COM": true, // host compare is case-insensitive
+ "https://open.feishu.cn": false,
+ "": false,
+ " ": false,
+ "https://mock.internal:8080": false,
+ "open.larksuite.com": false, // no scheme → not a usable override anyway
+ }
+ for in, want := range cases {
+ if got := isLarkInternationalHost(in); got != want {
+ t.Errorf("isLarkInternationalHost(%q) = %v, want %v", 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 7b81617c6a..9a69328847 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 292f7c548a..3c8bfa6b17 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 00641a9b8b..f41dbe2757 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 1667300171..e48e1a3a44 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 0000000000..910312b27c
--- /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 0000000000..f1b126cc2d
--- /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 0581a634a5..de0c86f6c8 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,10 +55,35 @@ func (q *Queries) AcquireLarkWSLease(ctx context.Context, arg AcquireLarkWSLease
&i.InstalledAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.BotUnionID,
+ &i.Region,
)
return i, err
}
+const backfillLarkInstallationRegionToLark = `-- name: BackfillLarkInstallationRegionToLark :execrows
+UPDATE lark_installation
+SET region = 'lark',
+ updated_at = now()
+WHERE region = 'feishu'
+`
+
+// Upgrade repair: flip every installation still carrying the migration-116
+// default ('feishu') to 'lark'. Called ONLY by
+// BackfillRegionFromLegacyOverride, and ONLY when the deployment's global
+// base-URL override pointed at Lark international — on such a deployment the
+// whole integration talked to open.larksuite.com, so every existing install
+// is really Lark and the migration's mainland default mislabels it.
+// Idempotent: once flipped there is nothing left at 'feishu' to update, and
+// new installs already carry the device-flow-detected region.
+func (q *Queries) BackfillLarkInstallationRegionToLark(ctx context.Context) (int64, error) {
+ result, err := q.db.Exec(ctx, backfillLarkInstallationRegionToLark)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
const claimLarkInboundDedup = `-- name: ClaimLarkInboundDedup :one
INSERT INTO lark_inbound_message_dedup (installation_id, message_id, claim_token)
@@ -250,7 +274,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 +326,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 +333,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 +508,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 +522,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 +529,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 +556,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 +563,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 +587,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 +594,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 +621,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 +628,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 +688,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 +713,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 +720,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 +777,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 +799,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 +806,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 +1014,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 +1037,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 +1073,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 +1084,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 +1100,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 +1119,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 +1130,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 +1137,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 d213c531fa..14eb2625ca 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 144871ccc5..8183870107 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,11 +47,26 @@ 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 *;
+-- name: BackfillLarkInstallationRegionToLark :execrows
+-- Upgrade repair: flip every installation still carrying the migration-116
+-- default ('feishu') to 'lark'. Called ONLY by
+-- BackfillRegionFromLegacyOverride, and ONLY when the deployment's global
+-- base-URL override pointed at Lark international — on such a deployment the
+-- whole integration talked to open.larksuite.com, so every existing install
+-- is really Lark and the migration's mainland default mislabels it.
+-- Idempotent: once flipped there is nothing left at 'feishu' to update, and
+-- new installs already carry the device-flow-detected region.
+UPDATE lark_installation
+SET region = 'lark',
+ updated_at = now()
+WHERE region = 'feishu';
+
-- name: SetLarkInstallationBotUnionID :exec
-- Operator-only backfill for installations created before the
-- bot_union_id column existed (migration 112). Production reads do