From 6ac8314711293c83d8a6158ab5598d3655683d98 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:03:13 +0800 Subject: [PATCH] feat(lark): support both Feishu and Lark from one deployment (MUL-3083) (#3815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat(lark): surface installation region in settings UI Read the per-installation region off the listings response: build the "Manage in Lark" dev-console host from it (open.feishu.cn vs open.larksuite.com instead of a hardcoded mainland host) and render a Feishu / Lark badge on each connected bot. The field is optional and defaults to Feishu when an older server omits it (API-compat). Adds the region_feishu / region_lark labels to all four locales. MUL-3083 Co-authored-by: multica-agent * docs(lark): document simultaneous Feishu + Lark support The cloud each bot belongs to is now auto-detected at install and stored per installation, so one deployment serves both. Replace the old "point MULTICA_LARK_HTTP_BASE_URL at larksuite for international tenants" guidance (now just an optional override) in all four locales. MUL-3083 Co-authored-by: multica-agent * fix(lark): repair legacy Lark-international installs on upgrade Review follow-up (MUL-3083). Migration 116 backfilled every existing lark_installation to region='feishu', assuming all historical rows were mainland. But self-host deployments could already run Lark international via the deployment-wide MULTICA_LARK_HTTP_BASE_URL override, so those rows are really Lark — clearing the override after upgrade (which the new docs invite) would route them to open.feishu.cn and break them. Add a one-shot startup repair, BackfillRegionFromLegacyOverride, fired off the hot path like BackfillBotUnionIDs: when the deployment's global base-URL override targets open.larksuite.com, relabel the still-default 'feishu' rows to 'lark'. Gating on the deployment-wide override is what makes it safe — every pre-existing install on such a deployment was Lark. Idempotent; no-op on mainland / fresh deployments. Verified end-to-end against a scratch DB (flip then 0-row idempotent re-run). Also document that a Lark/飞书 app_id is globally unique across both clouds, which is what makes the app_id-keyed token cache and the UNIQUE(app_id) constraint safe across regions (review nit). MUL-3083 Co-authored-by: multica-agent * docs(lark): fix ops guidance to match auto per-installation region Review follow-up (MUL-3083). .env.example and docker-compose.selfhost.yml still told operators that international Lark requires pointing both base URLs at open.larksuite.com — now wrong, and it would push a fresh deployment back into a single-cloud override. Rewrite them: the base URLs are optional deployment-wide overrides; normal dual-cloud operation keeps them empty. Document the first-boot auto-relabel for deployments migrating off the old single-cloud override, across the integration docs (en/zh/ja/ko). MUL-3083 Co-authored-by: multica-agent --------- Co-authored-by: J Co-authored-by: multica-agent --- .env.example | 13 +- .../content/docs/lark-bot-integration.ja.mdx | 8 +- .../content/docs/lark-bot-integration.ko.mdx | 8 +- .../content/docs/lark-bot-integration.mdx | 8 +- .../content/docs/lark-bot-integration.zh.mdx | 8 +- docker-compose.selfhost.yml | 13 +- packages/core/types/lark.ts | 6 + packages/views/locales/en/settings.json | 2 + packages/views/locales/ja/settings.json | 2 + packages/views/locales/ko/settings.json | 2 + packages/views/locales/zh-Hans/settings.json | 2 + .../settings/components/lark-tab.test.tsx | 26 +++ .../views/settings/components/lark-tab.tsx | 24 ++- server/cmd/server/router.go | 25 ++- server/internal/handler/lark.go | 11 +- server/internal/integrations/lark/client.go | 8 + .../internal/integrations/lark/http_client.go | 84 +++++--- server/internal/integrations/lark/hub.go | 6 +- .../integrations/lark/installation.go | 2 + server/internal/integrations/lark/outbound.go | 1 + .../integrations/lark/outcome_replier.go | 1 + .../integrations/lark/region_backfill.go | 72 +++++++ .../internal/integrations/lark/region_test.go | 198 ++++++++++++++++++ .../integrations/lark/registration_service.go | 19 +- server/internal/integrations/lark/types.go | 45 ++++ .../integrations/lark/union_id_backfill.go | 1 + .../internal/integrations/lark/ws_endpoint.go | 41 ++-- .../116_lark_installation_region.down.sql | 1 + .../116_lark_installation_region.up.sql | 22 ++ server/pkg/db/generated/lark.sql.go | 107 ++++++---- server/pkg/db/generated/models.go | 3 +- server/pkg/db/queries/lark.sql | 19 +- 32 files changed, 666 insertions(+), 122 deletions(-) create mode 100644 server/internal/integrations/lark/region_backfill.go create mode 100644 server/internal/integrations/lark/region_test.go create mode 100644 server/migrations/116_lark_installation_region.down.sql create mode 100644 server/migrations/116_lark_installation_region.up.sql diff --git a/.env.example b/.env.example index c528bd141..8dc31e93a 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 06da20bd1..86497d693 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 da9ae97bc..f5e12a274 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 2fd881c28..87b6e5d46 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 0427efe77..82d1e60da 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 92f829a5a..db089cce1 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 4798adcc6..86245e558 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 8d1f9681a..4695c8b88 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 7d7b34d2d..7ab4b02eb 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 a190e0a7f..acda5712e 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 8e63ba9ed..e3489568f 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 cb6e8daa5..3278d90d2 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 882d3c4e5..5e957d113 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 92d15cb22..9c040ca36 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 d6810d565..0902b8980 100644 --- a/server/internal/handler/lark.go +++ b/server/internal/handler/lark.go @@ -28,9 +28,13 @@ type LarkInstallationResponse struct { BotOpenID string `json:"bot_open_id"` InstallerUserID string `json:"installer_user_id"` Status string `json:"status"` - InstalledAt string `json:"installed_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // Region is the Lark cloud this installation lives on: "feishu" + // (mainland) or "lark" (international). The UI uses it to render a + // badge and to build the correct "Manage in Lark" dev-console host. + Region string `json:"region"` + InstalledAt string `json:"installed_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationResponse { @@ -42,6 +46,7 @@ func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationRespons BotOpenID: row.BotOpenID, InstallerUserID: uuidToString(row.InstallerUserID), Status: row.Status, + Region: row.Region, InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339), CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339), UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339), diff --git a/server/internal/integrations/lark/client.go b/server/internal/integrations/lark/client.go index 909091ac8..c11e401be 100644 --- a/server/internal/integrations/lark/client.go +++ b/server/internal/integrations/lark/client.go @@ -202,6 +202,14 @@ type InstallationCredentials struct { AppID string AppSecret string TenantKey string + // Region selects the Lark open-platform host (Feishu mainland vs + // Lark international) for every call made with these credentials. + // Empty defaults to Feishu. Credential-build sites copy it from + // lark_installation.region; the device-flow installer sets it from + // the auto-detected tenant. This is what lets one deployment serve + // both clouds — see http_client.go resolveBaseURL and + // ws_endpoint.go Endpoint. + Region Region } // ErrAPIClientNotConfigured is returned by the stub client to signal diff --git a/server/internal/integrations/lark/http_client.go b/server/internal/integrations/lark/http_client.go index 6a3ab1da5..76af73e7f 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 63bd50089..5063ab798 100644 --- a/server/internal/integrations/lark/hub.go +++ b/server/internal/integrations/lark/hub.go @@ -493,7 +493,11 @@ func leaseToken(nodeID string, gen uint64) string { // secret is never extracted; the encrypted ciphertext is fine to hash. func installationFingerprint(inst db.LarkInstallation) string { sum := sha256.Sum256(inst.AppSecretEncrypted) - return inst.AppID + "|" + inst.BotOpenID + "|" + hex.EncodeToString(sum[:]) + // region is part of the fingerprint: if a re-install corrects the + // cloud (e.g. a row mis-detected as feishu is re-scanned as lark), + // the WS bootstrap host changes, so the running supervisor must be + // torn down and restarted against the new host. + return inst.AppID + "|" + inst.BotOpenID + "|" + inst.Region + "|" + hex.EncodeToString(sum[:]) } // supervise owns one installation's connection lifecycle. It loops: diff --git a/server/internal/integrations/lark/installation.go b/server/internal/integrations/lark/installation.go index 3e1def9de..f4e51c974 100644 --- a/server/internal/integrations/lark/installation.go +++ b/server/internal/integrations/lark/installation.go @@ -25,6 +25,7 @@ type InstallationParams struct { TenantKey string // optional, "" treated as NULL BotOpenID string InstallerUserID pgtype.UUID + Region Region // which cloud (feishu/lark); empty defaults to feishu } // InstallationService creates, refreshes and revokes per-agent Lark @@ -71,6 +72,7 @@ func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams) TenantKey: textOrNull(p.TenantKey), BotOpenID: p.BotOpenID, InstallerUserID: p.InstallerUserID, + Region: string(RegionOrDefault(string(p.Region))), }) } diff --git a/server/internal/integrations/lark/outbound.go b/server/internal/integrations/lark/outbound.go index d6cea31c3..61fe6d799 100644 --- a/server/internal/integrations/lark/outbound.go +++ b/server/internal/integrations/lark/outbound.go @@ -374,6 +374,7 @@ func (p *Patcher) installationCredentials(inst db.LarkInstallation) (Installatio creds := InstallationCredentials{ AppID: inst.AppID, AppSecret: secret, + Region: RegionOrDefault(inst.Region), } if inst.TenantKey.Valid { creds.TenantKey = inst.TenantKey.String diff --git a/server/internal/integrations/lark/outcome_replier.go b/server/internal/integrations/lark/outcome_replier.go index f487cbd09..21eec0e1e 100644 --- a/server/internal/integrations/lark/outcome_replier.go +++ b/server/internal/integrations/lark/outcome_replier.go @@ -303,6 +303,7 @@ func (r *LarkOutcomeReplier) installationCredentials(inst db.LarkInstallation) ( creds := InstallationCredentials{ AppID: inst.AppID, AppSecret: secret, + Region: RegionOrDefault(inst.Region), } if inst.TenantKey.Valid { creds.TenantKey = inst.TenantKey.String diff --git a/server/internal/integrations/lark/region_backfill.go b/server/internal/integrations/lark/region_backfill.go new file mode 100644 index 000000000..a1096a898 --- /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 000000000..3af258417 --- /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 7b81617c6..9a6932884 100644 --- a/server/internal/integrations/lark/registration_service.go +++ b/server/internal/integrations/lark/registration_service.go @@ -407,6 +407,14 @@ func (s *RegistrationService) runPolling(sess *registrationSession) { } domain := sess.domain deviceCode := sess.deviceCode + // region tracks which cloud this install belongs to. It starts at + // Feishu (the begin host) and flips to Lark the moment the poll + // stream surfaces tenant_brand="lark" (the SwitchedDomain branch + // below). At finishSuccess time it is the authoritative per-install + // region, derived from the protocol's own role-based switch rather + // than by string-matching accounts hostnames (so staging/mock + // domains classify correctly too). + region := RegionFeishu for { select { @@ -444,11 +452,12 @@ func (s *RegistrationService) runPolling(sess *registrationSession) { // transition poll and the credential-bearing response // lands on the next call to the new domain. domain = res.SwitchedDomain + region = RegionLark s.cfg.Logger.Info("lark registration: switched to lark-international domain", "session_id", sess.id, "domain", domain) continue case res.ClientID != "" && res.ClientSecret != "": - s.finishSuccess(ctx, sess, res) + s.finishSuccess(ctx, sess, res, region) return case res.Err != nil: reason := RegistrationReasonProtocol @@ -473,8 +482,11 @@ func (s *RegistrationService) runPolling(sess *registrationSession) { // finishSuccess runs the post-poll finalization: bot info lookup + // installation insert + installer binding, all in a single DB // transaction. -func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrationSession, res *PollResult) { - creds := InstallationCredentials{AppID: res.ClientID, AppSecret: res.ClientSecret} +func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrationSession, res *PollResult, region Region) { + // Carry the detected region onto the credentials so the GetBotInfo + // call below hits the right open-platform host: a Lark-international + // install must reach open.larksuite.com, not the Feishu default. + creds := InstallationCredentials{AppID: res.ClientID, AppSecret: res.ClientSecret, Region: region} info, err := s.api.GetBotInfo(ctx, creds) if err != nil { s.cfg.Logger.Warn("lark registration: bot info failed", @@ -518,6 +530,7 @@ func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrat BotOpenID: string(info.OpenID), BotUnionID: textOrNull(info.UnionID), InstallerUserID: sess.initiatorID, + Region: string(region), }) if err != nil { s.cfg.Logger.Warn("lark registration: upsert installation", diff --git a/server/internal/integrations/lark/types.go b/server/internal/integrations/lark/types.go index 292f7c548..3c8bfa6b1 100644 --- a/server/internal/integrations/lark/types.go +++ b/server/internal/integrations/lark/types.go @@ -35,6 +35,51 @@ const ( InstallationRevoked InstallationStatus = "revoked" ) +// Region identifies which Lark open-platform cloud an installation lives +// on. Feishu (mainland China, open.feishu.cn / accounts.feishu.cn) and +// Lark (international, open.larksuite.com / accounts.larksuite.com) are +// separate clouds with distinct hosts; a single Multica deployment serves +// both by resolving the host per installation from this value rather than +// from a deployment-wide env var. Mirrors the lark_installation.region +// CHECK constraint (migration 116) — keep the two in lockstep. +type Region string + +const ( + RegionFeishu Region = "feishu" + RegionLark Region = "lark" +) + +// larkInternationalOpenBaseURL is the open-platform host for the Lark +// international cloud. The Feishu (mainland) counterpart is +// defaultLarkBaseURL ("https://open.feishu.cn"), defined in http_client.go; +// it doubles as the WS long-conn bootstrap host (the /callback/ws/endpoint +// POST runs against the same open-platform host). +const larkInternationalOpenBaseURL = "https://open.larksuite.com" + +// OpenPlatformBaseURL maps a region to its open-platform host — the base +// URL for both the REST API (http_client.go) and the WebSocket +// /callback/ws/endpoint bootstrap (ws_endpoint.go). An unset or unknown +// region falls back to Feishu (mainland), which is the default every +// pre-region installation row carries. +func (r Region) OpenPlatformBaseURL() string { + if r == RegionLark { + return larkInternationalOpenBaseURL + } + return defaultLarkBaseURL +} + +// RegionOrDefault normalizes a stored region string (originating from the +// lark_installation.region column) to a Region, defaulting to Feishu for +// empty or unrecognized values so a malformed row never resolves to an +// empty host (or a CHECK-violating write). Exported because the router's +// WS credentials provider (package main) hydrates creds from the raw row. +func RegionOrDefault(s string) Region { + if Region(s) == RegionLark { + return RegionLark + } + return RegionFeishu +} + // DropReason enumerates the categories the inbound pipeline writes // into lark_inbound_audit.drop_reason. The DB column is open TEXT so // new reasons can be added without a migration; callers should reuse diff --git a/server/internal/integrations/lark/union_id_backfill.go b/server/internal/integrations/lark/union_id_backfill.go index 00641a9b8..f41dbe275 100644 --- a/server/internal/integrations/lark/union_id_backfill.go +++ b/server/internal/integrations/lark/union_id_backfill.go @@ -81,6 +81,7 @@ func BackfillBotUnionIDs( AppID: row.AppID, AppSecret: secret, TenantKey: row.TenantKey.String, + Region: RegionOrDefault(row.Region), }) cancel() if err != nil { diff --git a/server/internal/integrations/lark/ws_endpoint.go b/server/internal/integrations/lark/ws_endpoint.go index 166730017..e48e1a3a4 100644 --- a/server/internal/integrations/lark/ws_endpoint.go +++ b/server/internal/integrations/lark/ws_endpoint.go @@ -15,13 +15,14 @@ import ( "time" ) -// defaultLarkCallbackBaseURL is the bootstrap host for the long-conn -// `/callback/ws/endpoint` request. Note this is `open.feishu.cn` -// (mainland) regardless of where the WS itself ends up — Lark returns -// the wss URL in the response body. Operators on the international -// tenant override via MULTICA_LARK_CALLBACK_BASE_URL to -// `https://open.larksuite.com`. -const defaultLarkCallbackBaseURL = "https://open.feishu.cn" +// The bootstrap host for the long-conn `/callback/ws/endpoint` request +// is the installation's open-platform host — open.feishu.cn for Feishu +// (mainland), open.larksuite.com for Lark (international) — resolved per +// call from InstallationCredentials.Region via Region.OpenPlatformBaseURL +// (Lark returns the actual wss URL in the response body, so only the +// bootstrap POST host has to be region-aware). A deployment-wide +// MULTICA_LARK_CALLBACK_BASE_URL still overrides every installation when +// set (staging / mock). // HTTPConnectionTokenFetcher is the production EndpointFetcher. It // exchanges per-installation app credentials for a short-lived @@ -51,9 +52,11 @@ type HTTPConnectionTokenFetcher struct { cfg HTTPConnectionTokenConfig } -// HTTPConnectionTokenConfig wires the fetcher's dependencies. BaseURL -// defaults to defaultLarkCallbackBaseURL; tests substitute an -// httptest.Server URL. +// HTTPConnectionTokenConfig wires the fetcher's dependencies. BaseURL is +// an optional deployment-wide override; when empty (the production +// default) Endpoint() resolves the bootstrap host per installation from +// the region. Tests substitute an httptest.Server URL to force all +// regions to the fake server. type HTTPConnectionTokenConfig struct { BaseURL string HTTPClient *http.Client @@ -62,9 +65,12 @@ type HTTPConnectionTokenConfig struct { } func (c HTTPConnectionTokenConfig) withDefaults() HTTPConnectionTokenConfig { - if c.BaseURL == "" { - c.BaseURL = defaultLarkCallbackBaseURL - } + // BaseURL is intentionally NOT defaulted here. Empty means "no + // deployment-wide override" — Endpoint() then resolves the bootstrap + // host per installation from InstallationCredentials.Region, so one + // fetcher serves both Feishu and Lark. A non-empty BaseURL + // (MULTICA_LARK_CALLBACK_BASE_URL, or an httptest URL in tests) + // forces every installation to that host. c.BaseURL = strings.TrimRight(c.BaseURL, "/") if c.HTTPClient == nil { c.HTTPClient = &http.Client{Timeout: defaultRequestTimeout} @@ -119,7 +125,14 @@ func (f *HTTPConnectionTokenFetcher) Endpoint(ctx context.Context, creds Install if err != nil { return WSEndpoint{}, fmt.Errorf("marshal body: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.BaseURL+"/callback/ws/endpoint", bytes.NewReader(raw)) + // Resolve the bootstrap host per call: an explicit cfg.BaseURL + // override wins (env / httptest), otherwise the installation's region + // picks Feishu vs Lark so one fetcher serves both clouds. + base := f.cfg.BaseURL + if base == "" { + base = creds.Region.OpenPlatformBaseURL() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/callback/ws/endpoint", bytes.NewReader(raw)) if err != nil { return WSEndpoint{}, fmt.Errorf("new request: %w", err) } diff --git a/server/migrations/116_lark_installation_region.down.sql b/server/migrations/116_lark_installation_region.down.sql new file mode 100644 index 000000000..910312b27 --- /dev/null +++ b/server/migrations/116_lark_installation_region.down.sql @@ -0,0 +1 @@ +ALTER TABLE lark_installation DROP COLUMN region; diff --git a/server/migrations/116_lark_installation_region.up.sql b/server/migrations/116_lark_installation_region.up.sql new file mode 100644 index 000000000..f1b126cc2 --- /dev/null +++ b/server/migrations/116_lark_installation_region.up.sql @@ -0,0 +1,22 @@ +-- Add a per-installation `region` so one Multica deployment can serve +-- BOTH mainland Feishu (open.feishu.cn / accounts.feishu.cn) and Lark +-- international (open.larksuite.com / accounts.larksuite.com) at the same +-- time. Before this column the open-platform host was a single +-- deployment-wide value (the MULTICA_LARK_HTTP_BASE_URL / +-- MULTICA_LARK_CALLBACK_BASE_URL env knobs, defaulting to open.feishu.cn), +-- so a given deployment could talk to only one cloud at a time. +-- +-- The device-flow installer already auto-detects the tenant: Lark emits +-- user_info.tenant_brand="lark" mid-poll and RegistrationService swaps the +-- accounts host to accounts.larksuite.com. finishSuccess now persists that +-- detected region here, and every outbound REST + WebSocket call resolves +-- its open-platform host from this column via InstallationCredentials.Region. +-- +-- NOT NULL DEFAULT 'feishu' is the safe backfill: every installation that +-- exists today was created against mainland Feishu (the only host the old +-- code reached without an env override), so 'feishu' is correct for all +-- pre-migration rows. The CHECK mirrors the lark.Region enum in +-- server/internal/integrations/lark/types.go — keep the two in lockstep. +ALTER TABLE lark_installation + ADD COLUMN region TEXT NOT NULL DEFAULT 'feishu' + CHECK (region IN ('feishu', 'lark')); diff --git a/server/pkg/db/generated/lark.sql.go b/server/pkg/db/generated/lark.sql.go index 0581a634a..de0c86f6c 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 d213c531f..14eb2625c 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -453,7 +453,6 @@ type LarkInstallation struct { AppSecretEncrypted []byte `json:"app_secret_encrypted"` TenantKey pgtype.Text `json:"tenant_key"` BotOpenID string `json:"bot_open_id"` - BotUnionID pgtype.Text `json:"bot_union_id"` InstallerUserID pgtype.UUID `json:"installer_user_id"` Status string `json:"status"` WsLeaseToken pgtype.Text `json:"ws_lease_token"` @@ -461,6 +460,8 @@ type LarkInstallation struct { InstalledAt pgtype.Timestamptz `json:"installed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + BotUnionID pgtype.Text `json:"bot_union_id"` + Region string `json:"region"` } type LarkOutboundCardMessage struct { diff --git a/server/pkg/db/queries/lark.sql b/server/pkg/db/queries/lark.sql index 144871ccc..818387010 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