mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
13
.env.example
13
.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=
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 次に
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 다음
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "切断しています…",
|
||||
|
||||
@@ -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": "연결을 해제하는 중…",
|
||||
|
||||
@@ -233,6 +233,8 @@
|
||||
"empty_description_cta": "绑定到飞书",
|
||||
"empty_description_suffix": "即可为它安装 Bot。",
|
||||
"revoked_badge": "已撤销",
|
||||
"region_feishu": "飞书",
|
||||
"region_lark": "Lark",
|
||||
"installed_at_label": "安装于 {{when}}",
|
||||
"disconnect": "断开连接",
|
||||
"disconnecting": "正在断开…",
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
72
server/internal/integrations/lark/region_backfill.go
Normal file
72
server/internal/integrations/lark/region_backfill.go
Normal 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")
|
||||
}
|
||||
198
server/internal/integrations/lark/region_test.go
Normal file
198
server/internal/integrations/lark/region_test.go
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,6 +81,7 @@ func BackfillBotUnionIDs(
|
||||
AppID: row.AppID,
|
||||
AppSecret: secret,
|
||||
TenantKey: row.TenantKey.String,
|
||||
Region: RegionOrDefault(row.Region),
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
1
server/migrations/116_lark_installation_region.down.sql
Normal file
1
server/migrations/116_lark_installation_region.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE lark_installation DROP COLUMN region;
|
||||
22
server/migrations/116_lark_installation_region.up.sql
Normal file
22
server/migrations/116_lark_installation_region.up.sql
Normal 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'));
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user