feat(lark): support both Feishu and Lark from one deployment (MUL-3083) (#3815)

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

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

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

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

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

* 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 <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-06-05 16:03:13 +08:00
committed by GitHub
parent 93b93f58b5
commit 6ac8314711
32 changed files with 666 additions and 122 deletions

View File

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

View File

@@ -80,11 +80,11 @@ Multica Cloud では連携はすでに利用可能です——このセクショ
2. API を再起動します。キーを設定するまで、**設定 → 連携** には「Lark integration not enabled」という通知が表示され、**Lark に紐づける** のエントリポイントは非表示のままになります。
<Callout type="info">
**国際版テナント。** 連携はデフォルトで中国大陸のホスト(`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 デプロイでは操作は不要です。
</Callout>
## 次に

View File

@@ -80,11 +80,11 @@ Multica Cloud에서는 연동이 이미 사용 가능합니다 — 이 섹션은
2. API를 재시작하세요. 키가 설정되기 전까지 **설정 → Integrations**에는 "Lark integration not enabled" 안내가 표시되고, **Bind to Lark** 진입점은 숨겨진 채로 유지됩니다.
<Callout type="info">
**국제판 테넌트.** 연동은 기본적으로 중국 본토 호스트(`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 배포에서는 별도 작업이 필요 없습니다.
</Callout>
## 다음

View File

@@ -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.
<Callout type="info">
**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.
</Callout>
## Next

View File

@@ -80,11 +80,11 @@ import { Callout } from "fumadocs-ui/components/callout";
2. 重启 API。在密钥设置好之前**设置 → 集成** 会显示「未启用飞书集成」提示,**绑定到飞书** 入口也会保持隐藏。
<Callout type="info">
**国际版租户。** 集成默认走中国大陆主机`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之后你就可以清掉这个覆盖项。国内飞书部署无需任何操作。
</Callout>
## 下一步

View File

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

View File

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

View File

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

View File

@@ -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": "切断しています…",

View File

@@ -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": "연결을 해제하는 중…",

View File

@@ -233,6 +233,8 @@
"empty_description_cta": "绑定到飞书",
"empty_description_suffix": "即可为它安装 Bot。",
"revoked_badge": "已撤销",
"region_feishu": "飞书",
"region_lark": "Lark",
"installed_at_label": "安装于 {{when}}",
"disconnect": "断开连接",
"disconnecting": "正在断开…",

View File

@@ -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(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
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 = [
{

View File

@@ -232,6 +232,11 @@ function InstallationRow({
<div className="space-y-1">
<p className="text-sm font-medium">
{agentName}
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{installation.region === "lark"
? t(($) => $.lark.region_lark)
: t(($) => $.lark.region_feishu)}
</span>
{!isActive && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{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 (
<div className={className} data-testid="lark-agent-bot-connected">
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">

View File

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

View File

@@ -28,9 +28,13 @@ type LarkInstallationResponse struct {
BotOpenID string `json:"bot_open_id"`
InstallerUserID string `json:"installer_user_id"`
Status string `json:"status"`
InstalledAt string `json:"installed_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// Region is the Lark cloud this installation lives on: "feishu"
// (mainland) or "lark" (international). The UI uses it to render a
// badge and to build the correct "Manage in Lark" dev-console host.
Region string `json:"region"`
InstalledAt string `json:"installed_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationResponse {
@@ -42,6 +46,7 @@ func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationRespons
BotOpenID: row.BotOpenID,
InstallerUserID: uuidToString(row.InstallerUserID),
Status: row.Status,
Region: row.Region,
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),

View File

@@ -202,6 +202,14 @@ type InstallationCredentials struct {
AppID string
AppSecret string
TenantKey string
// Region selects the Lark open-platform host (Feishu mainland vs
// Lark international) for every call made with these credentials.
// Empty defaults to Feishu. Credential-build sites copy it from
// lark_installation.region; the device-flow installer sets it from
// the auto-detected tenant. This is what lets one deployment serve
// both clouds — see http_client.go resolveBaseURL and
// ws_endpoint.go Endpoint.
Region Region
}
// ErrAPIClientNotConfigured is returned by the stub client to signal

View File

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

View File

@@ -493,7 +493,11 @@ func leaseToken(nodeID string, gen uint64) string {
// secret is never extracted; the encrypted ciphertext is fine to hash.
func installationFingerprint(inst db.LarkInstallation) string {
sum := sha256.Sum256(inst.AppSecretEncrypted)
return inst.AppID + "|" + inst.BotOpenID + "|" + hex.EncodeToString(sum[:])
// region is part of the fingerprint: if a re-install corrects the
// cloud (e.g. a row mis-detected as feishu is re-scanned as lark),
// the WS bootstrap host changes, so the running supervisor must be
// torn down and restarted against the new host.
return inst.AppID + "|" + inst.BotOpenID + "|" + inst.Region + "|" + hex.EncodeToString(sum[:])
}
// supervise owns one installation's connection lifecycle. It loops:

View File

@@ -25,6 +25,7 @@ type InstallationParams struct {
TenantKey string // optional, "" treated as NULL
BotOpenID string
InstallerUserID pgtype.UUID
Region Region // which cloud (feishu/lark); empty defaults to feishu
}
// InstallationService creates, refreshes and revokes per-agent Lark
@@ -71,6 +72,7 @@ func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams)
TenantKey: textOrNull(p.TenantKey),
BotOpenID: p.BotOpenID,
InstallerUserID: p.InstallerUserID,
Region: string(RegionOrDefault(string(p.Region))),
})
}

View File

@@ -374,6 +374,7 @@ func (p *Patcher) installationCredentials(inst db.LarkInstallation) (Installatio
creds := InstallationCredentials{
AppID: inst.AppID,
AppSecret: secret,
Region: RegionOrDefault(inst.Region),
}
if inst.TenantKey.Valid {
creds.TenantKey = inst.TenantKey.String

View File

@@ -303,6 +303,7 @@ func (r *LarkOutcomeReplier) installationCredentials(inst db.LarkInstallation) (
creds := InstallationCredentials{
AppID: inst.AppID,
AppSecret: secret,
Region: RegionOrDefault(inst.Region),
}
if inst.TenantKey.Valid {
creds.TenantKey = inst.TenantKey.String

View File

@@ -0,0 +1,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")
}

View File

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

View File

@@ -407,6 +407,14 @@ func (s *RegistrationService) runPolling(sess *registrationSession) {
}
domain := sess.domain
deviceCode := sess.deviceCode
// region tracks which cloud this install belongs to. It starts at
// Feishu (the begin host) and flips to Lark the moment the poll
// stream surfaces tenant_brand="lark" (the SwitchedDomain branch
// below). At finishSuccess time it is the authoritative per-install
// region, derived from the protocol's own role-based switch rather
// than by string-matching accounts hostnames (so staging/mock
// domains classify correctly too).
region := RegionFeishu
for {
select {
@@ -444,11 +452,12 @@ func (s *RegistrationService) runPolling(sess *registrationSession) {
// transition poll and the credential-bearing response
// lands on the next call to the new domain.
domain = res.SwitchedDomain
region = RegionLark
s.cfg.Logger.Info("lark registration: switched to lark-international domain",
"session_id", sess.id, "domain", domain)
continue
case res.ClientID != "" && res.ClientSecret != "":
s.finishSuccess(ctx, sess, res)
s.finishSuccess(ctx, sess, res, region)
return
case res.Err != nil:
reason := RegistrationReasonProtocol
@@ -473,8 +482,11 @@ func (s *RegistrationService) runPolling(sess *registrationSession) {
// finishSuccess runs the post-poll finalization: bot info lookup +
// installation insert + installer binding, all in a single DB
// transaction.
func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrationSession, res *PollResult) {
creds := InstallationCredentials{AppID: res.ClientID, AppSecret: res.ClientSecret}
func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrationSession, res *PollResult, region Region) {
// Carry the detected region onto the credentials so the GetBotInfo
// call below hits the right open-platform host: a Lark-international
// install must reach open.larksuite.com, not the Feishu default.
creds := InstallationCredentials{AppID: res.ClientID, AppSecret: res.ClientSecret, Region: region}
info, err := s.api.GetBotInfo(ctx, creds)
if err != nil {
s.cfg.Logger.Warn("lark registration: bot info failed",
@@ -518,6 +530,7 @@ func (s *RegistrationService) finishSuccess(ctx context.Context, sess *registrat
BotOpenID: string(info.OpenID),
BotUnionID: textOrNull(info.UnionID),
InstallerUserID: sess.initiatorID,
Region: string(region),
})
if err != nil {
s.cfg.Logger.Warn("lark registration: upsert installation",

View File

@@ -35,6 +35,51 @@ const (
InstallationRevoked InstallationStatus = "revoked"
)
// Region identifies which Lark open-platform cloud an installation lives
// on. Feishu (mainland China, open.feishu.cn / accounts.feishu.cn) and
// Lark (international, open.larksuite.com / accounts.larksuite.com) are
// separate clouds with distinct hosts; a single Multica deployment serves
// both by resolving the host per installation from this value rather than
// from a deployment-wide env var. Mirrors the lark_installation.region
// CHECK constraint (migration 116) — keep the two in lockstep.
type Region string
const (
RegionFeishu Region = "feishu"
RegionLark Region = "lark"
)
// larkInternationalOpenBaseURL is the open-platform host for the Lark
// international cloud. The Feishu (mainland) counterpart is
// defaultLarkBaseURL ("https://open.feishu.cn"), defined in http_client.go;
// it doubles as the WS long-conn bootstrap host (the /callback/ws/endpoint
// POST runs against the same open-platform host).
const larkInternationalOpenBaseURL = "https://open.larksuite.com"
// OpenPlatformBaseURL maps a region to its open-platform host — the base
// URL for both the REST API (http_client.go) and the WebSocket
// /callback/ws/endpoint bootstrap (ws_endpoint.go). An unset or unknown
// region falls back to Feishu (mainland), which is the default every
// pre-region installation row carries.
func (r Region) OpenPlatformBaseURL() string {
if r == RegionLark {
return larkInternationalOpenBaseURL
}
return defaultLarkBaseURL
}
// RegionOrDefault normalizes a stored region string (originating from the
// lark_installation.region column) to a Region, defaulting to Feishu for
// empty or unrecognized values so a malformed row never resolves to an
// empty host (or a CHECK-violating write). Exported because the router's
// WS credentials provider (package main) hydrates creds from the raw row.
func RegionOrDefault(s string) Region {
if Region(s) == RegionLark {
return RegionLark
}
return RegionFeishu
}
// DropReason enumerates the categories the inbound pipeline writes
// into lark_inbound_audit.drop_reason. The DB column is open TEXT so
// new reasons can be added without a migration; callers should reuse

View File

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

View File

@@ -15,13 +15,14 @@ import (
"time"
)
// defaultLarkCallbackBaseURL is the bootstrap host for the long-conn
// `/callback/ws/endpoint` request. Note this is `open.feishu.cn`
// (mainland) regardless of where the WS itself ends up — Lark returns
// the wss URL in the response body. Operators on the international
// tenant override via MULTICA_LARK_CALLBACK_BASE_URL to
// `https://open.larksuite.com`.
const defaultLarkCallbackBaseURL = "https://open.feishu.cn"
// The bootstrap host for the long-conn `/callback/ws/endpoint` request
// is the installation's open-platform host — open.feishu.cn for Feishu
// (mainland), open.larksuite.com for Lark (international) — resolved per
// call from InstallationCredentials.Region via Region.OpenPlatformBaseURL
// (Lark returns the actual wss URL in the response body, so only the
// bootstrap POST host has to be region-aware). A deployment-wide
// MULTICA_LARK_CALLBACK_BASE_URL still overrides every installation when
// set (staging / mock).
// HTTPConnectionTokenFetcher is the production EndpointFetcher. It
// exchanges per-installation app credentials for a short-lived
@@ -51,9 +52,11 @@ type HTTPConnectionTokenFetcher struct {
cfg HTTPConnectionTokenConfig
}
// HTTPConnectionTokenConfig wires the fetcher's dependencies. BaseURL
// defaults to defaultLarkCallbackBaseURL; tests substitute an
// httptest.Server URL.
// HTTPConnectionTokenConfig wires the fetcher's dependencies. BaseURL is
// an optional deployment-wide override; when empty (the production
// default) Endpoint() resolves the bootstrap host per installation from
// the region. Tests substitute an httptest.Server URL to force all
// regions to the fake server.
type HTTPConnectionTokenConfig struct {
BaseURL string
HTTPClient *http.Client
@@ -62,9 +65,12 @@ type HTTPConnectionTokenConfig struct {
}
func (c HTTPConnectionTokenConfig) withDefaults() HTTPConnectionTokenConfig {
if c.BaseURL == "" {
c.BaseURL = defaultLarkCallbackBaseURL
}
// BaseURL is intentionally NOT defaulted here. Empty means "no
// deployment-wide override" — Endpoint() then resolves the bootstrap
// host per installation from InstallationCredentials.Region, so one
// fetcher serves both Feishu and Lark. A non-empty BaseURL
// (MULTICA_LARK_CALLBACK_BASE_URL, or an httptest URL in tests)
// forces every installation to that host.
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
if c.HTTPClient == nil {
c.HTTPClient = &http.Client{Timeout: defaultRequestTimeout}
@@ -119,7 +125,14 @@ func (f *HTTPConnectionTokenFetcher) Endpoint(ctx context.Context, creds Install
if err != nil {
return WSEndpoint{}, fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.BaseURL+"/callback/ws/endpoint", bytes.NewReader(raw))
// Resolve the bootstrap host per call: an explicit cfg.BaseURL
// override wins (env / httptest), otherwise the installation's region
// picks Feishu vs Lark so one fetcher serves both clouds.
base := f.cfg.BaseURL
if base == "" {
base = creds.Region.OpenPlatformBaseURL()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/callback/ws/endpoint", bytes.NewReader(raw))
if err != nil {
return WSEndpoint{}, fmt.Errorf("new request: %w", err)
}

View File

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

View File

@@ -0,0 +1,22 @@
-- Add a per-installation `region` so one Multica deployment can serve
-- BOTH mainland Feishu (open.feishu.cn / accounts.feishu.cn) and Lark
-- international (open.larksuite.com / accounts.larksuite.com) at the same
-- time. Before this column the open-platform host was a single
-- deployment-wide value (the MULTICA_LARK_HTTP_BASE_URL /
-- MULTICA_LARK_CALLBACK_BASE_URL env knobs, defaulting to open.feishu.cn),
-- so a given deployment could talk to only one cloud at a time.
--
-- The device-flow installer already auto-detects the tenant: Lark emits
-- user_info.tenant_brand="lark" mid-poll and RegistrationService swaps the
-- accounts host to accounts.larksuite.com. finishSuccess now persists that
-- detected region here, and every outbound REST + WebSocket call resolves
-- its open-platform host from this column via InstallationCredentials.Region.
--
-- NOT NULL DEFAULT 'feishu' is the safe backfill: every installation that
-- exists today was created against mainland Feishu (the only host the old
-- code reached without an env override), so 'feishu' is correct for all
-- pre-migration rows. The CHECK mirrors the lark.Region enum in
-- server/internal/integrations/lark/types.go — keep the two in lockstep.
ALTER TABLE lark_installation
ADD COLUMN region TEXT NOT NULL DEFAULT 'feishu'
CHECK (region IN ('feishu', 'lark'));

View File

@@ -23,7 +23,7 @@ WHERE id = $3
OR ws_lease_expires_at < now()
OR ws_lease_token = $1
)
RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, bot_union_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at
RETURNING id, workspace_id, agent_id, app_id, app_secret_encrypted, tenant_key, bot_open_id, installer_user_id, status, ws_lease_token, ws_lease_expires_at, installed_at, created_at, updated_at, bot_union_id, region
`
type AcquireLarkWSLeaseParams struct {
@@ -48,7 +48,6 @@ func (q *Queries) AcquireLarkWSLease(ctx context.Context, arg AcquireLarkWSLease
&i.AppSecretEncrypted,
&i.TenantKey,
&i.BotOpenID,
&i.BotUnionID,
&i.InstallerUserID,
&i.Status,
&i.WsLeaseToken,
@@ -56,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
}

View File

@@ -453,7 +453,6 @@ type LarkInstallation struct {
AppSecretEncrypted []byte `json:"app_secret_encrypted"`
TenantKey pgtype.Text `json:"tenant_key"`
BotOpenID string `json:"bot_open_id"`
BotUnionID pgtype.Text `json:"bot_union_id"`
InstallerUserID pgtype.UUID `json:"installer_user_id"`
Status string `json:"status"`
WsLeaseToken pgtype.Text `json:"ws_lease_token"`
@@ -461,6 +460,8 @@ type LarkInstallation struct {
InstalledAt pgtype.Timestamptz `json:"installed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
BotUnionID pgtype.Text `json:"bot_union_id"`
Region string `json:"region"`
}
type LarkOutboundCardMessage struct {

View File

@@ -36,9 +36,9 @@ RETURNING *;
-- lifecycle.
INSERT INTO lark_installation (
workspace_id, agent_id, app_id, app_secret_encrypted,
tenant_key, bot_open_id, bot_union_id, installer_user_id
tenant_key, bot_open_id, bot_union_id, installer_user_id, region
) VALUES (
$1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6
$1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6, sqlc.arg('region')
)
ON CONFLICT (workspace_id, agent_id) DO UPDATE SET
app_id = EXCLUDED.app_id,
@@ -47,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