Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
f6d368213e Merge remote-tracking branch 'origin/main' into j/lark-bind-split-entries
# Conflicts:
#	packages/views/settings/components/lark-tab.tsx
2026-06-05 18:29:48 +08:00
Eve
51dc62b155 fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu
Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.

Backend (must-fix from review)

The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.

- PollResult now carries SwitchedDomain AND SwitchedRegion in
  lockstep, so the caller never has to re-derive region from the
  domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
  host symmetrically with the existing tenant_brand=lark check, gated
  on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
  hardcoded RegionLark — the SwitchedDomain branch now flips both
  feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
  to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
  for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
  (table-driven, both directions) to pin that the gate doesn't loop.

Backend (nit from review)

Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).

Frontend (nit from review)

The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:

- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}

across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.

Desktop (separate report)

Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.

context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 18:19:52 +08:00
Eve
999dd13fc7 feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)
The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.

Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.

Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
  c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
  unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
  and onto runPolling's initial region local. SwitchedDomain still
  flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
  with empty defaulting to feishu for back-compat.

Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
  so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
  a single dialogRegion useState so an "open but with no region picked"
  intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
  region-aware copy (title, description, scan hint, link fallback,
  success toast).

i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
  install_scan_hint_*, install_open_link_fallback_*, and
  install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
  single-region keys are kept for now; nothing in the tree references
  them anymore but a follow-up cleanup can remove them once the dust
  settles.

Tests
- Two new lark.RegistrationClient tests pin region routing in both
  directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
  beginLarkInstall with the matching region argument. Existing CTA
  tests updated to expect both buttons in place of one.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 17:53:08 +08:00
13 changed files with 971 additions and 83 deletions

View File

@@ -0,0 +1,221 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Capture every MenuItem the SUT constructs so each test can assert
// on the menu that would appear at popup time without booting an
// actual Electron window. State is created via `vi.hoisted` because
// `vi.mock` factories are hoisted above all top-level statements; a
// plain `const` would be a TDZ ReferenceError when the factory runs.
type CapturedMenuItem = {
label?: string;
role?: string;
type?: string;
click?: () => void;
};
const ctx = vi.hoisted(() => ({
capturedItems: [] as CapturedMenuItem[][],
browserWindowFromWebContents: vi.fn(),
popupSpy: vi.fn(),
clipboardWriteText: vi.fn(),
openExternalSpy: vi.fn().mockResolvedValue(undefined),
preferredLanguagesRef: { current: ["en-US"] as string[] },
}));
vi.mock("electron", () => {
class MockMenu {
items: CapturedMenuItem[] = [];
constructor() {
ctx.capturedItems.push(this.items);
}
append(item: CapturedMenuItem) {
this.items.push(item);
}
popup(opts: unknown) {
ctx.popupSpy(opts);
}
}
class MockMenuItem {
label?: string;
role?: string;
type?: string;
click?: () => void;
constructor(opts: CapturedMenuItem) {
Object.assign(this, opts);
}
}
return {
BrowserWindow: { fromWebContents: ctx.browserWindowFromWebContents },
Menu: MockMenu,
MenuItem: MockMenuItem,
app: {
getPreferredSystemLanguages: () => ctx.preferredLanguagesRef.current,
},
clipboard: { writeText: ctx.clipboardWriteText },
shell: { openExternal: ctx.openExternalSpy },
};
});
import { installContextMenu } from "./context-menu";
type ContextMenuParams = {
selectionText: string;
isEditable: boolean;
linkURL: string;
editFlags: {
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
};
type Listener = (event: unknown, params: ContextMenuParams) => void;
// Tiny WebContents stub — we only need the `.on("context-menu", ...)`
// hook the SUT installs and a way to fire it back at our own listener
// list. Everything else (clipboard, link opening, label resolution) is
// mocked above.
function makeWebContents() {
const handlers: Listener[] = [];
return {
on(event: string, fn: Listener) {
if (event === "context-menu") handlers.push(fn);
},
fire(params: ContextMenuParams) {
for (const h of handlers) h({}, params);
},
};
}
const baseEditFlags = {
canCut: false,
canCopy: false,
canPaste: false,
canSelectAll: false,
};
describe("installContextMenu — link items", () => {
beforeEach(() => {
ctx.capturedItems.length = 0;
ctx.popupSpy.mockClear();
ctx.clipboardWriteText.mockClear();
ctx.openExternalSpy.mockClear();
ctx.browserWindowFromWebContents.mockReset();
ctx.preferredLanguagesRef.current = ["en-US"];
});
it("adds 'Open Link in Browser' and 'Copy Link Address' when right-clicking an http(s) link", () => {
// The link case is the one this test file is here to cover —
// before MUL-3083 follow-up, right-clicking an <a> in the
// renderer only surfaced 'copy' (when the user happened to have
// text selected) and gave no way to open the URL externally.
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire({
...baseSelection({ linkURL: "https://multica.ai/welcome" }),
});
const labels = lastMenuLabels();
expect(labels).toContain("Open Link in Browser");
expect(labels).toContain("Copy Link Address");
// The two click handlers must route to the existing
// openExternalSafely allowlist + clipboard.writeText.
invokeByLabel("Open Link in Browser");
expect(ctx.openExternalSpy).toHaveBeenCalledWith("https://multica.ai/welcome");
invokeByLabel("Copy Link Address");
expect(ctx.clipboardWriteText).toHaveBeenCalledWith(
"https://multica.ai/welcome",
);
expect(ctx.popupSpy).toHaveBeenCalledTimes(1);
});
it("does NOT add link items when the cursor is over a non-http(s) URL", () => {
// Only http(s) links are surfaced — we don't promise anything for
// mailto:, javascript:, custom app schemes, etc. Surfacing them
// would shell out via openExternalSafely (which would block the
// call anyway) or write a non-URL string to the clipboard, both
// of which violate user expectations for a "link" item.
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "javascript:alert(1)" }));
const labels = lastMenuLabelsOrEmpty();
expect(labels).not.toContain("Open Link in Browser");
expect(labels).not.toContain("Copy Link Address");
});
it("does NOT add link items when there is no link under the cursor", () => {
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire({
selectionText: "hello",
isEditable: false,
linkURL: "",
editFlags: { ...baseEditFlags, canCopy: true },
});
const labels = lastMenuLabelsOrEmpty();
expect(labels).not.toContain("Open Link in Browser");
// Selection-only context still surfaces copy as before — guards
// against a regression where adding the link branch broke the
// base path.
expect(menuItemRoles()).toContain("copy");
});
it("uses zh-Hans labels when the OS preferred language is Chinese", () => {
// Locale fallback is intentionally permissive: every zh-* variant
// routes to zh-Hans so users on zh-CN / zh-TW / zh-HK still see
// Chinese rather than dropping to English. The renderer ships only
// zh-Hans translations, so this matches the rest of the app.
ctx.preferredLanguagesRef.current = ["zh-CN"];
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
expect(lastMenuLabels()).toContain("在浏览器中打开链接");
expect(lastMenuLabels()).toContain("复制链接地址");
});
it("falls back to English when the OS preferred language is something we don't ship", () => {
ctx.preferredLanguagesRef.current = ["fr-FR"];
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
expect(lastMenuLabels()).toContain("Open Link in Browser");
});
});
// --- helpers ---
function baseSelection(over: Partial<ContextMenuParams>): ContextMenuParams {
return {
selectionText: "",
isEditable: false,
linkURL: "",
editFlags: { ...baseEditFlags },
...over,
};
}
function lastMenu(): CapturedMenuItem[] {
const last = ctx.capturedItems[ctx.capturedItems.length - 1];
if (!last) throw new Error("no menu was constructed");
return last;
}
function lastMenuLabelsOrEmpty(): string[] {
const last = ctx.capturedItems[ctx.capturedItems.length - 1] ?? [];
return last.map((i) => i.label ?? "");
}
function lastMenuLabels(): string[] {
return lastMenu().map((i) => i.label ?? "");
}
function menuItemRoles(): string[] {
return lastMenu().map((i) => i.role ?? "");
}
function invokeByLabel(label: string): void {
const item = lastMenu().find((i) => i.label === label);
if (!item) throw new Error(`menu item not found: ${label}`);
item.click?.();
}

View File

@@ -1,12 +1,38 @@
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
import {
BrowserWindow,
Menu,
MenuItem,
app,
clipboard,
type WebContents,
} from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
// Electron ships with no default right-click menu, so a user selecting text
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
// menu using `roles`, which keeps i18n + accelerator handling native.
//
// Custom (non-role) link items below are NOT auto-localized by Electron —
// roles like "copy" pull labels from the OS, but a custom MenuItem only
// shows the `label` you give it. We translate by OS-preferred language so
// the link items at least track Chinese / Japanese / Korean speakers
// alongside the English default; everything else falls through to English,
// which matches Chrome's behavior on those locales without app-level
// translation files.
export function installContextMenu(webContents: WebContents): void {
webContents.on("context-menu", (_event, params) => {
const { editFlags, selectionText, isEditable } = params;
const { editFlags, selectionText, isEditable, linkURL } = params;
const hasSelection = selectionText.trim().length > 0;
// params.linkURL is the resolved absolute URL of the anchor under the
// cursor; Electron normalizes relative hrefs against the page URL for
// us, so we only need to gate on the http(s) scheme allowlist
// (mirrors openExternalSafely + the renderer's <a> usage). Empty for
// non-link right-clicks; other schemes (mailto:, javascript:, custom
// app schemes) are intentionally not surfaced — opening them via
// shell.openExternal would route through the OS handler and is
// outside what this menu promises.
const linkIsHttpUrl = !!linkURL && isSafeExternalHttpUrl(linkURL);
const labels = pickLabels();
const menu = new Menu();
@@ -26,8 +52,87 @@ export function installContextMenu(webContents: WebContents): void {
menu.append(new MenuItem({ role: "selectAll" }));
}
// Link items — only when the cursor is over an actual http(s) <a>.
// Without these the renderer's <a target="_blank"> gives users no
// standard right-click affordance ("Open in new window", "Copy link
// address"); the default click handler does forward to
// setWindowOpenHandler → openExternalSafely, but discoverability via
// the keyboard / mouse context menu was missing.
if (linkIsHttpUrl) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(
new MenuItem({
label: labels.openLink,
click: () => {
// openExternalSafely re-validates the scheme — defense in
// depth in case Electron ever surfaces a non-http linkURL
// we forgot to filter at this layer.
void openExternalSafely(linkURL);
},
}),
);
menu.append(
new MenuItem({
label: labels.copyLinkAddress,
click: () => {
clipboard.writeText(linkURL);
},
}),
);
}
if (menu.items.length === 0) return;
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
menu.popup({ window });
});
}
// Labels for the two link-related menu items in the user's OS-preferred
// language, with English as the fallback. Kept inline because the main
// process has no shared i18n loader (the renderer's i18next is per-window
// and not reachable from here), and pulling one in for two strings would
// be more rope than payload. Matches the four locales the renderer ships.
type ContextMenuLabels = {
openLink: string;
copyLinkAddress: string;
};
const labelsByLocale: Record<string, ContextMenuLabels> = {
en: {
openLink: "Open Link in Browser",
copyLinkAddress: "Copy Link Address",
},
"zh-Hans": {
openLink: "在浏览器中打开链接",
copyLinkAddress: "复制链接地址",
},
ja: {
openLink: "ブラウザでリンクを開く",
copyLinkAddress: "リンクのアドレスをコピー",
},
ko: {
openLink: "브라우저에서 링크 열기",
copyLinkAddress: "링크 주소 복사",
},
};
// pickLabels resolves the OS-preferred language to one of the four
// locales we ship copy for. We say "Open Link in Browser" rather than
// "Open Link in New Window" because the link is opened via
// shell.openExternal — it lands in the user's default browser, not in
// another Multica window — so the wording matches what actually
// happens.
function pickLabels(): ContextMenuLabels {
const preferred = app.getPreferredSystemLanguages()[0]?.toLowerCase() ?? "";
if (preferred.startsWith("zh")) {
// All Chinese variants get the Simplified copy — Multica only
// ships zh-Hans, and zh-Hant users falling through to en would be
// worse than reading Simplified Chinese.
return labelsByLocale["zh-Hans"];
}
if (preferred.startsWith("ja")) return labelsByLocale.ja;
if (preferred.startsWith("ko")) return labelsByLocale.ko;
return labelsByLocale.en;
}

View File

@@ -2065,8 +2065,20 @@ export class ApiClient {
return this.fetch(`/api/workspaces/${workspaceId}/lark/installations`);
}
async beginLarkInstall(workspaceId: string, agentId: string): Promise<BeginLarkInstallResponse> {
const search = new URLSearchParams({ agent_id: agentId });
async beginLarkInstall(
workspaceId: string,
agentId: string,
region: "feishu" | "lark",
): Promise<BeginLarkInstallResponse> {
// The user picks the cloud explicitly in the UI ("Bind to Feishu"
// vs "Bind to Lark"), and the backend POSTs the device-flow `begin`
// against the corresponding accounts host (accounts.feishu.cn vs
// accounts.larksuite.com) so the QR renders against the right
// cloud up front. Empty / omitted region still resolves to Feishu
// server-side (RegionOrDefault) — we surface region as a required
// arg here so every call site is forced to make a deliberate
// choice rather than silently defaulting to mainland.
const search = new URLSearchParams({ agent_id: agentId, region });
return this.fetch(`/api/workspaces/${workspaceId}/lark/install/begin?${search.toString()}`, {
method: "POST",
});

View File

@@ -245,18 +245,40 @@
"toast_disconnect_failed": "Disconnect failed",
"bind_button": "Bind to Lark",
"bind_button_title": "Bind {{agent}} to a Lark Bot",
"bind_button_feishu": "Bind to Feishu",
"bind_button_lark": "Bind to Lark",
"bind_button_feishu_title": "Bind {{agent}} to a Feishu (mainland) Bot",
"bind_button_lark_title": "Bind {{agent}} to a Lark (international) Bot",
"agent_bot_connected_label": "Connected to Lark",
"agent_bot_manage_link": "Manage in Lark",
"agent_bot_manage_tooltip": "Open this Bot's app page in Lark — manage scopes, edit display name, request additional permissions.",
"agent_bot_connected_label_feishu": "Connected to Feishu",
"agent_bot_connected_label_lark": "Connected to Lark",
"agent_bot_manage_link_feishu": "Manage in Feishu",
"agent_bot_manage_link_lark": "Manage in Lark",
"agent_bot_manage_tooltip_feishu": "Open this Bot's app page in Feishu — manage scopes, edit display name, request additional permissions.",
"agent_bot_manage_tooltip_lark": "Open this Bot's app page in Lark — manage scopes, edit display name, request additional permissions.",
"agent_bot_disconnect_tooltip": "Unbind this Lark Bot from the Agent. The Bot will stop receiving Lark messages.",
"install_dialog_title": "Bind to Lark",
"install_dialog_description": "Open Lark on your phone and scan the code below to create a PersonalAgent Bot.",
"install_dialog_description_for_agent": "Open Lark on your phone and scan the code below to create a Bot for {{agent}}.",
"install_dialog_title_feishu": "Bind to Feishu",
"install_dialog_title_lark": "Bind to Lark",
"install_dialog_description_feishu": "Open Feishu on your phone and scan the code below to create a PersonalAgent Bot.",
"install_dialog_description_lark": "Open Lark on your phone and scan the code below to create a PersonalAgent Bot.",
"install_dialog_description_for_agent_feishu": "Open Feishu on your phone and scan the code below to create a Bot for {{agent}}.",
"install_dialog_description_for_agent_lark": "Open Lark on your phone and scan the code below to create a Bot for {{agent}}.",
"install_starting": "Preparing QR code…",
"install_scan_hint": "Lark → Scan → authorize the new PersonalAgent.",
"install_open_link_fallback": "Or tap here to open in Lark",
"install_success": "Install complete — your Bot is online.",
"install_success_toast": "Lark Bot installed",
"install_scan_hint_feishu": "Feishu → Scan → authorize the new PersonalAgent.",
"install_scan_hint_lark": "Lark → Scan → authorize the new PersonalAgent.",
"install_open_link_fallback_feishu": "Or tap here to open in Feishu",
"install_open_link_fallback_lark": "Or tap here to open in Lark",
"install_success_toast_feishu": "Feishu Bot installed",
"install_success_toast_lark": "Lark Bot installed",
"install_close": "Close",
"install_retry": "Scan again",
"install_poll_retry": "Network blip while checking install status — retrying.",

View File

@@ -245,18 +245,40 @@
"toast_disconnect_failed": "切断に失敗しました",
"bind_button": "Lark に接続",
"bind_button_title": "{{agent}} を Lark ボットに接続",
"bind_button_feishu": "Feishu に接続",
"bind_button_lark": "Lark に接続",
"bind_button_feishu_title": "{{agent}} を Feishu中国本土版ボットに接続",
"bind_button_lark_title": "{{agent}} を Lark国際版ボットに接続",
"agent_bot_connected_label": "Lark に接続済み",
"agent_bot_manage_link": "Lark で管理",
"agent_bot_manage_tooltip": "Lark のデベロッパーコンソールでこのボットのアプリページを開き、スコープ管理 / 名称変更 / 追加権限の申請ができます。",
"agent_bot_connected_label_feishu": "Feishu に接続済み",
"agent_bot_connected_label_lark": "Lark に接続済み",
"agent_bot_manage_link_feishu": "Feishu で管理",
"agent_bot_manage_link_lark": "Lark で管理",
"agent_bot_manage_tooltip_feishu": "Feishu のデベロッパーコンソールでこのボットのアプリページを開き、スコープ管理 / 名称変更 / 追加権限の申請ができます。",
"agent_bot_manage_tooltip_lark": "Lark のデベロッパーコンソールでこのボットのアプリページを開き、スコープ管理 / 名称変更 / 追加権限の申請ができます。",
"agent_bot_disconnect_tooltip": "この Lark ボットとエージェントの連携を解除します。ボットは Lark メッセージの受信を停止します。",
"install_dialog_title": "Lark に接続",
"install_dialog_description": "Lark を開き、以下のコードをスキャンして PersonalAgent ボットを作成してください。",
"install_dialog_description_for_agent": "Lark を開き、以下のコードをスキャンして {{agent}} 用のボットを作成してください。",
"install_dialog_title_feishu": "Feishu に接続",
"install_dialog_title_lark": "Lark に接続",
"install_dialog_description_feishu": "Feishu を開き、以下のコードをスキャンして PersonalAgent ボットを作成してください。",
"install_dialog_description_lark": "Lark を開き、以下のコードをスキャンして PersonalAgent ボットを作成してください。",
"install_dialog_description_for_agent_feishu": "Feishu を開き、以下のコードをスキャンして {{agent}} 用のボットを作成してください。",
"install_dialog_description_for_agent_lark": "Lark を開き、以下のコードをスキャンして {{agent}} 用のボットを作成してください。",
"install_starting": "QR コードを準備しています…",
"install_scan_hint": "Lark → スキャン → 新しい PersonalAgent を承認してください。",
"install_open_link_fallback": "ここをタップして Lark で開く",
"install_success": "設置が完了しました — ボットはオンラインです。",
"install_success_toast": "Lark ボットを設置しました",
"install_scan_hint_feishu": "Feishu → スキャン → 新しい PersonalAgent を承認してください。",
"install_scan_hint_lark": "Lark → スキャン → 新しい PersonalAgent を承認してください。",
"install_open_link_fallback_feishu": "ここをタップして Feishu で開く",
"install_open_link_fallback_lark": "ここをタップして Lark で開く",
"install_success_toast_feishu": "Feishu ボットを設置しました",
"install_success_toast_lark": "Lark ボットを設置しました",
"install_close": "閉じる",
"install_retry": "もう一度スキャン",
"install_poll_retry": "設置状況の確認中に一時的にネットワークが途切れました — 再試行します。",

View File

@@ -322,18 +322,40 @@
"toast_disconnect_failed": "연결 해제에 실패했어요",
"bind_button": "Lark에 연결",
"bind_button_title": "{{agent}}을(를) Lark 봇에 연결",
"bind_button_feishu": "Feishu에 연결",
"bind_button_lark": "Lark에 연결",
"bind_button_feishu_title": "{{agent}}을(를) Feishu(중국 본토판) 봇에 연결",
"bind_button_lark_title": "{{agent}}을(를) Lark(글로벌판) 봇에 연결",
"agent_bot_connected_label": "Lark에 연결됨",
"agent_bot_manage_link": "Lark에서 관리",
"agent_bot_manage_tooltip": "Lark 개발자 콘솔에서 이 봇의 앱 페이지를 열어 스코프 관리, 이름 변경, 추가 권한 신청을 할 수 있어요.",
"agent_bot_connected_label_feishu": "Feishu에 연결됨",
"agent_bot_connected_label_lark": "Lark에 연결됨",
"agent_bot_manage_link_feishu": "Feishu에서 관리",
"agent_bot_manage_link_lark": "Lark에서 관리",
"agent_bot_manage_tooltip_feishu": "Feishu 개발자 콘솔에서 이 봇의 앱 페이지를 열어 스코프 관리, 이름 변경, 추가 권한 신청을 할 수 있어요.",
"agent_bot_manage_tooltip_lark": "Lark 개발자 콘솔에서 이 봇의 앱 페이지를 열어 스코프 관리, 이름 변경, 추가 권한 신청을 할 수 있어요.",
"agent_bot_disconnect_tooltip": "이 Lark 봇과 에이전트 연결을 해제합니다. 봇은 Lark 메시지 수신을 중단합니다.",
"install_dialog_title": "Lark에 연결",
"install_dialog_description": "휴대폰에서 Lark를 열고 아래 코드를 스캔해 PersonalAgent 봇을 만드세요.",
"install_dialog_description_for_agent": "휴대폰에서 Lark를 열고 아래 코드를 스캔해 {{agent}}용 봇을 만드세요.",
"install_dialog_title_feishu": "Feishu에 연결",
"install_dialog_title_lark": "Lark에 연결",
"install_dialog_description_feishu": "휴대폰에서 Feishu를 열고 아래 코드를 스캔해 PersonalAgent 봇을 만드세요.",
"install_dialog_description_lark": "휴대폰에서 Lark를 열고 아래 코드를 스캔해 PersonalAgent 봇을 만드세요.",
"install_dialog_description_for_agent_feishu": "휴대폰에서 Feishu를 열고 아래 코드를 스캔해 {{agent}}용 봇을 만드세요.",
"install_dialog_description_for_agent_lark": "휴대폰에서 Lark를 열고 아래 코드를 스캔해 {{agent}}용 봇을 만드세요.",
"install_starting": "QR 코드를 준비하는 중…",
"install_scan_hint": "Lark → 스캔 → 새 PersonalAgent를 승인하세요.",
"install_open_link_fallback": "또는 여기를 눌러 Lark에서 열기",
"install_success": "설치 완료 — 봇이 온라인 상태입니다.",
"install_success_toast": "Lark 봇이 설치되었어요",
"install_scan_hint_feishu": "Feishu → 스캔 → 새 PersonalAgent를 승인하세요.",
"install_scan_hint_lark": "Lark → 스캔 → 새 PersonalAgent를 승인하세요.",
"install_open_link_fallback_feishu": "또는 여기를 눌러 Feishu에서 열기",
"install_open_link_fallback_lark": "또는 여기를 눌러 Lark에서 열기",
"install_success_toast_feishu": "Feishu 봇이 설치되었어요",
"install_success_toast_lark": "Lark 봇이 설치되었어요",
"install_close": "닫기",
"install_retry": "다시 스캔",
"install_poll_retry": "설치 상태를 확인하는 중 네트워크가 일시적으로 끊겼어요 — 다시 시도합니다.",

View File

@@ -245,18 +245,40 @@
"toast_disconnect_failed": "断开失败",
"bind_button": "绑定到飞书",
"bind_button_title": "将 {{agent}} 绑定到飞书 Bot",
"bind_button_feishu": "绑定飞书",
"bind_button_lark": "绑定 Lark",
"bind_button_feishu_title": "将 {{agent}} 绑定到飞书国内版Bot",
"bind_button_lark_title": "将 {{agent}} 绑定到 Lark海外版Bot",
"agent_bot_connected_label": "已连接到飞书",
"agent_bot_manage_link": "在飞书中管理",
"agent_bot_manage_tooltip": "打开飞书开发者控制台中该 Bot 的应用页面——管理权限、修改名称、申请新的权限范围。",
"agent_bot_connected_label_feishu": "已连接到飞书",
"agent_bot_connected_label_lark": "已连接到 Lark",
"agent_bot_manage_link_feishu": "在飞书中管理",
"agent_bot_manage_link_lark": "在 Lark 中管理",
"agent_bot_manage_tooltip_feishu": "打开飞书开发者控制台中该 Bot 的应用页面——管理权限、修改名称、申请新的权限范围。",
"agent_bot_manage_tooltip_lark": "打开 Lark 开发者控制台中该 Bot 的应用页面——管理权限、修改名称、申请新的权限范围。",
"agent_bot_disconnect_tooltip": "将该飞书 Bot 与智能体解绑Bot 将停止接收飞书消息。",
"install_dialog_title": "绑定到飞书",
"install_dialog_description": "用飞书扫描下方二维码,创建一个 PersonalAgent Bot。",
"install_dialog_description_for_agent": "用飞书扫描下方二维码,为 {{agent}} 创建一个 Bot。",
"install_dialog_title_feishu": "绑定到飞书",
"install_dialog_title_lark": "绑定到 Lark",
"install_dialog_description_feishu": "用飞书扫描下方二维码,创建一个 PersonalAgent Bot。",
"install_dialog_description_lark": "用 Lark 扫描下方二维码,创建一个 PersonalAgent Bot。",
"install_dialog_description_for_agent_feishu": "用飞书扫描下方二维码,为 {{agent}} 创建一个 Bot。",
"install_dialog_description_for_agent_lark": "用 Lark 扫描下方二维码,为 {{agent}} 创建一个 Bot。",
"install_starting": "正在准备二维码…",
"install_scan_hint": "飞书 → 扫一扫 → 授权新 PersonalAgent。",
"install_open_link_fallback": "或点这里直接在飞书中打开",
"install_success": "安装完成Bot 已上线。",
"install_success_toast": "飞书 Bot 已安装",
"install_scan_hint_feishu": "飞书 → 扫一扫 → 授权新 PersonalAgent。",
"install_scan_hint_lark": "Lark → 扫一扫 → 授权新 PersonalAgent。",
"install_open_link_fallback_feishu": "或点这里直接在飞书中打开",
"install_open_link_fallback_lark": "或点这里直接在 Lark 中打开",
"install_success_toast_feishu": "飞书 Bot 已安装",
"install_success_toast_lark": "Lark Bot 已安装",
"install_close": "关闭",
"install_retry": "重新扫码",
"install_poll_retry": "网络抖动,正在重试查询安装状态。",

View File

@@ -187,22 +187,31 @@ function resetFixtures() {
describe("LarkAgentBindButton (CTA gate)", () => {
beforeEach(resetFixtures);
it("renders the bind CTA when the viewer is a workspace owner and install is supported", () => {
it("renders both Feishu and Lark bind CTAs when the viewer is a workspace owner and install is supported", () => {
// The CTA was split into two explicit entry points — one per cloud
// — so the begin POST hits the right accounts host up front (no
// tenant-brand mid-poll auto-switch from a Feishu-first start) and
// the QR / dialog copy reflects the cloud the user picked. Both
// buttons must mount side by side for owners/admins; either one
// alone would re-introduce the "Lark user has to scan a Feishu QR"
// confusion this split is meant to remove (MUL-3083 follow-up).
render(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
wrapper: I18nWrapper,
});
expect(screen.getByRole("button", { name: /Bind to Feishu/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /Bind to Lark/i })).toBeTruthy();
});
it("renders the bind CTA when the viewer is a workspace admin", () => {
it("renders both bind CTAs when the viewer is a workspace admin", () => {
membersRef.current = [{ user_id: "user-1", role: "admin" }];
render(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
wrapper: I18nWrapper,
});
expect(screen.getByRole("button", { name: /Bind to Feishu/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /Bind to Lark/i })).toBeTruthy();
});
it("hides the bind CTA for a non-admin agent owner (matches backend admin gate)", () => {
it("hides both bind CTAs for a non-admin agent owner (matches backend admin gate)", () => {
membersRef.current = [{ user_id: "user-1", role: "member" }];
const { container } = render(
<LarkAgentBindButton agentId="agent-1" agentName="Bot" />,
@@ -211,7 +220,7 @@ describe("LarkAgentBindButton (CTA gate)", () => {
expect(container.querySelector("button")).toBeNull();
});
it("hides the bind CTA when the device-flow install path is not wired on the server", () => {
it("hides both bind CTAs when the device-flow install path is not wired on the server", () => {
installationsRef.current.install_supported = false;
const { container } = render(
<LarkAgentBindButton agentId="agent-1" agentName="Bot" />,
@@ -220,7 +229,59 @@ describe("LarkAgentBindButton (CTA gate)", () => {
expect(container.querySelector("button")).toBeNull();
});
it("swaps the bind CTA for a 'Connected + Manage in Lark' badge when this agent already has an active installation", () => {
it("clicking Bind to Feishu begins an install with region='feishu'", async () => {
// Pin the routing wire-up: each split CTA must pass its own region
// string to the API client (which threads it onto the
// /lark/install/begin?region=… query param), so the device-flow
// begins on the matching accounts host. A regression here would
// silently send Lark users to a Feishu QR — the exact bug this
// refactor addresses.
const user = userEvent.setup();
mockBeginInstall.mockResolvedValue({
session_id: "sess-feishu",
qr_code_url: "https://accounts.feishu.cn/oauth/v1/device?u=feishu",
expires_in_seconds: 300,
poll_interval_seconds: 2,
});
mockGetStatus.mockResolvedValue({ status: "pending" });
render(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
wrapper: I18nWrapper,
});
await user.click(screen.getByRole("button", { name: /Bind to Feishu/i }));
await waitFor(() => {
expect(mockBeginInstall).toHaveBeenCalledTimes(1);
});
expect(mockBeginInstall).toHaveBeenCalledWith(
"workspace-1",
"agent-1",
"feishu",
);
});
it("clicking Bind to Lark begins an install with region='lark'", async () => {
const user = userEvent.setup();
mockBeginInstall.mockResolvedValue({
session_id: "sess-lark",
qr_code_url: "https://accounts.larksuite.com/oauth/v1/device?u=lark",
expires_in_seconds: 300,
poll_interval_seconds: 2,
});
mockGetStatus.mockResolvedValue({ status: "pending" });
render(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
wrapper: I18nWrapper,
});
await user.click(screen.getByRole("button", { name: /Bind to Lark/i }));
await waitFor(() => {
expect(mockBeginInstall).toHaveBeenCalledTimes(1);
});
expect(mockBeginInstall).toHaveBeenCalledWith(
"workspace-1",
"agent-1",
"lark",
);
});
it("swaps the bind CTAs for a 'Connected + Manage in Lark' badge when this agent already has an active installation", () => {
// Anti-zombie guard: re-scanning the same agent upserts the row
// and orphans the previously-created Lark PersonalAgent. The badge
// closes the install entry point and links the user to the Bot's
@@ -244,20 +305,30 @@ describe("LarkAgentBindButton (CTA gate)", () => {
<LarkAgentBindButton agentId="agent-1" agentName="Bot" />,
{ wrapper: I18nWrapper },
);
// The Bind CTA must be gone — re-scanning would orphan the
// Both Bind CTAs must be gone — re-scanning would orphan the
// PersonalAgent (see badge comment in lark-tab.tsx).
expect(screen.queryByRole("button", { name: /Bind to Feishu/i })).toBeNull();
expect(screen.queryByRole("button", { name: /Bind to Lark/i })).toBeNull();
expect(screen.getByText(/Connected to Lark/i)).toBeTruthy();
const link = screen.getByRole("link", { name: /Manage in Lark/i }) as HTMLAnchorElement;
// The fixture omits `region`, which the listings DTO defaults to
// Feishu (mainland). After the #3830 badge restructure the cloud is
// shown as a "Feishu" chip (not baked into the connected label) and a
// Disconnect action appears; the region-aware Manage link still points
// at the mainland host.
expect(screen.getByText("Feishu")).toBeTruthy();
expect(screen.getByTestId("lark-agent-bot-disconnect")).toBeTruthy();
const link = screen.getByRole("link", { name: /Manage in Feishu/i }) as HTMLAnchorElement;
expect(link.href).toBe("https://open.feishu.cn/app/cli_existing_app");
expect(link.target).toBe("_blank");
expect(link.rel).toContain("noopener");
});
it("points the Manage link at open.larksuite.com for a Lark-international (region=lark) installation", () => {
it("renders region-aware badge text and Manage link 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.
// must show "Connected to Lark" + "Manage in Lark" copy, with the
// Manage link pointing at open.larksuite.com (not the Feishu
// default). Without region-aware copy a user who clicked
// "Bind to Feishu" and saw "Connected to Lark" would (rightly) be
// confused — the labels must match the cloud the bot lives on.
installationsRef.current.installations = [
{
id: "inst-lark",
@@ -276,11 +347,12 @@ describe("LarkAgentBindButton (CTA gate)", () => {
render(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
wrapper: I18nWrapper,
});
expect(screen.getByText(/Connected to Lark/i)).toBeTruthy();
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)", () => {
it("still shows both bind CTAs when an installation exists for a DIFFERENT agent (per-agent scoping)", () => {
installationsRef.current.installations = [
{
id: "inst-other",
@@ -298,6 +370,7 @@ describe("LarkAgentBindButton (CTA gate)", () => {
render(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
wrapper: I18nWrapper,
});
expect(screen.getByRole("button", { name: /Bind to Feishu/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /Bind to Lark/i })).toBeTruthy();
});
@@ -326,16 +399,21 @@ describe("LarkAgentBindButton (CTA gate)", () => {
<LarkAgentBindButton agentId="agent-1" agentName="Bot" />,
{ wrapper: I18nWrapper },
);
// The Bind CTA must be gone even when install_supported=false,
// Both Bind CTAs must be gone even when install_supported=false,
// since the existing-installation check runs first.
expect(screen.queryByRole("button", { name: /Bind to Feishu/i })).toBeNull();
expect(screen.queryByRole("button", { name: /Bind to Lark/i })).toBeNull();
expect(screen.getByText(/Connected to Lark/i)).toBeTruthy();
// Fixture omits region → defaults to Feishu: the cloud shows as a
// "Feishu" chip (post-#3830 badge restructure), the Disconnect action
// is present, and the Manage link stays Feishu-aware.
expect(screen.getByText("Feishu")).toBeTruthy();
expect(screen.getByTestId("lark-agent-bot-disconnect")).toBeTruthy();
expect(
screen.getByRole("link", { name: /Manage in Lark/i }),
screen.getByRole("link", { name: /Manage in Feishu/i }),
).toBeTruthy();
});
it("still shows the bind CTA when this agent's only installation is revoked (treat as not-installed for re-bind)", () => {
it("still shows both bind CTAs when this agent's only installation is revoked (treat as not-installed for re-bind)", () => {
installationsRef.current.installations = [
{
id: "inst-revoked",
@@ -353,6 +431,7 @@ describe("LarkAgentBindButton (CTA gate)", () => {
render(<LarkAgentBindButton agentId="agent-1" agentName="Bot" />, {
wrapper: I18nWrapper,
});
expect(screen.getByRole("button", { name: /Bind to Feishu/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /Bind to Lark/i })).toBeTruthy();
});
});
@@ -391,7 +470,8 @@ describe("LarkAgentBotConnectedBadge (Unbind / Disconnect)", () => {
// we don't trip over /Disconnect/i copy that also appears in the
// (closed) AlertDialog.
expect(screen.getByTestId("lark-agent-bot-disconnect")).toBeTruthy();
expect(screen.getByRole("link", { name: /Manage in Lark/i })).toBeTruthy();
// Fixture omits region → Feishu copy.
expect(screen.getByRole("link", { name: /Manage in Feishu/i })).toBeTruthy();
});
it("opens the confirm dialog and does NOT call the API until the user confirms", async () => {

View File

@@ -305,7 +305,15 @@ export function LarkAgentBindButton({
const { t } = useT("settings");
const wsId = useWorkspaceId();
const user = useAuthStore((s) => s.user);
const [dialogOpen, setDialogOpen] = useState(false);
// dialogRegion carries two pieces of state in one variable: which
// cloud the dialog should target (drives the device-flow `begin`
// host and the dialog copy), AND whether the dialog is open at all
// (null = closed). A separate boolean would have to be kept in sync
// with the region — collapsing them prevents an "open but with no
// region picked" intermediate state from existing.
const [dialogRegion, setDialogRegion] = useState<"feishu" | "lark" | null>(
null,
);
const { data: listing } = useQuery({
...larkInstallationsOptions(wsId),
@@ -349,25 +357,61 @@ export function LarkAgentBindButton({
// a fresh scan would fail at the post-poll bot-info step, so hide the CTA.
if (!installSupported) return null;
// Two CTAs, one per cloud — Feishu (mainland) on the left, Lark
// (international) on the right. We deliberately render two explicit
// entry points instead of one auto-detect QR because Lark only emits
// tenant_brand="lark" mid-poll AFTER the user has authorized; until
// then a Lark user has to scan a QR served from accounts.feishu.cn,
// which has surfaced as confusing for international users (MUL-3083
// follow-up). Each button passes its region to the install dialog,
// which threads it to the backend so the device-flow `begin` POSTs
// directly against the matching accounts host. The mid-poll
// tenant-brand auto-switch in RegistrationService is preserved as a
// safety net for users who pick the wrong entry.
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
disabled={!agentId}
className={className}
title={agentName ? t(($) => $.lark.bind_button_title, { agent: agentName }) : undefined}
<div
className={cn("flex flex-wrap items-center gap-2", className)}
data-testid="lark-agent-bind-buttons"
>
<ExternalLink className="h-3 w-3" />
{t(($) => $.lark.bind_button)}
</Button>
{dialogOpen && (
<Button
variant="outline"
size="sm"
onClick={() => setDialogRegion("feishu")}
disabled={!agentId}
title={
agentName
? t(($) => $.lark.bind_button_feishu_title, { agent: agentName })
: undefined
}
data-testid="lark-agent-bind-feishu"
>
<ExternalLink className="h-3 w-3" />
{t(($) => $.lark.bind_button_feishu)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDialogRegion("lark")}
disabled={!agentId}
title={
agentName
? t(($) => $.lark.bind_button_lark_title, { agent: agentName })
: undefined
}
data-testid="lark-agent-bind-lark"
>
<ExternalLink className="h-3 w-3" />
{t(($) => $.lark.bind_button_lark)}
</Button>
</div>
{dialogRegion && (
<LarkInstallDialog
wsId={wsId}
agentId={agentId}
agentName={agentName}
onClose={() => setDialogOpen(false)}
region={dialogRegion}
onClose={() => setDialogRegion(null)}
/>
)}
</>
@@ -517,16 +561,23 @@ function LarkAgentBotConnectedBadge({
{/* Row 2: secondary "Manage in Lark" link to the Bot's dev-console
app page. Demoted below the status row so it no longer competes
with the primary connect/disconnect intents. */}
with the primary connect/disconnect intents. Region-aware tooltip
keeps the Feishu vs Lark distinction this branch introduced. */}
<a
href={manageHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
title={t(($) => $.lark.agent_bot_manage_tooltip)}
title={
installation.region === "lark"
? t(($) => $.lark.agent_bot_manage_tooltip_lark)
: t(($) => $.lark.agent_bot_manage_tooltip_feishu)
}
>
<ExternalLink className="h-3 w-3" />
{t(($) => $.lark.agent_bot_manage_link)}
{installation.region === "lark"
? t(($) => $.lark.agent_bot_manage_link_lark)
: t(($) => $.lark.agent_bot_manage_link_feishu)}
</a>
<AlertDialog
@@ -564,7 +615,7 @@ function LarkAgentBotConnectedBadge({
}
// LarkInstallDialog walks the user through the device-flow install:
// 1) POST /lark/install/begin → render QR
// 1) POST /lark/install/begin?region=<feishu|lark> → render QR
// 2) poll /lark/install/{sessionId}/status until success | error | expiry
// 3) on success: toast, close, invalidate installations cache
//
@@ -572,15 +623,23 @@ function LarkAgentBotConnectedBadge({
// rather than reusing a stale device_code — Lark's device_code is
// single-use and a re-render of the same QR after an error would just
// fail again at the next poll.
//
// region is a required prop so the begin POST hits the right cloud
// (accounts.feishu.cn vs accounts.larksuite.com) and the dialog copy
// (title, scan hint, link fallback) reflects the cloud the user
// picked. Defaulting it would silently route Lark users to a Feishu QR
// — exactly the confusion this split-CTA refactor is meant to remove.
function LarkInstallDialog({
wsId,
agentId,
agentName,
region,
onClose,
}: {
wsId: string;
agentId: string;
agentName?: string;
region: "feishu" | "lark";
onClose: () => void;
}) {
const { t } = useT("settings");
@@ -614,7 +673,7 @@ function LarkInstallDialog({
setErrorMessage(null);
setSession(null);
try {
const res = await api.beginLarkInstall(wsId, agentId);
const res = await api.beginLarkInstall(wsId, agentId, region);
if (closedRef.current) return;
setSession({
sessionId: res.session_id,
@@ -671,7 +730,11 @@ function LarkInstallDialog({
setStatus(res.status);
if (res.status === "success") {
await qc.invalidateQueries({ queryKey: larkKeys.installations(wsId) });
toast.success(t(($) => $.lark.install_success_toast));
toast.success(
region === "lark"
? t(($) => $.lark.install_success_toast_lark)
: t(($) => $.lark.install_success_toast_feishu),
);
// Close after a tiny beat so the user sees the success state
// briefly before the dialog disappears.
setTimeout(() => {
@@ -742,11 +805,19 @@ function LarkInstallDialog({
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t(($) => $.lark.install_dialog_title)}</DialogTitle>
<DialogTitle>
{region === "lark"
? t(($) => $.lark.install_dialog_title_lark)
: t(($) => $.lark.install_dialog_title_feishu)}
</DialogTitle>
<DialogDescription>
{agentName
? t(($) => $.lark.install_dialog_description_for_agent, { agent: agentName })
: t(($) => $.lark.install_dialog_description)}
{region === "lark"
? agentName
? t(($) => $.lark.install_dialog_description_for_agent_lark, { agent: agentName })
: t(($) => $.lark.install_dialog_description_lark)
: agentName
? t(($) => $.lark.install_dialog_description_for_agent_feishu, { agent: agentName })
: t(($) => $.lark.install_dialog_description_feishu)}
</DialogDescription>
</DialogHeader>
@@ -763,7 +834,9 @@ function LarkInstallDialog({
<QRCode value={session.qrCodeURL} size={192} />
</div>
<p className="text-center text-xs text-muted-foreground">
{t(($) => $.lark.install_scan_hint)}
{region === "lark"
? t(($) => $.lark.install_scan_hint_lark)
: t(($) => $.lark.install_scan_hint_feishu)}
</p>
<a
href={session.qrCodeURL}
@@ -771,7 +844,9 @@ function LarkInstallDialog({
rel="noopener noreferrer"
className="text-xs underline text-muted-foreground"
>
{t(($) => $.lark.install_open_link_fallback)}
{region === "lark"
? t(($) => $.lark.install_open_link_fallback_lark)
: t(($) => $.lark.install_open_link_fallback_feishu)}
</a>
</>
)}

View File

@@ -265,6 +265,26 @@ func (h *Handler) BeginLarkInstall(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
// region is the cloud the user explicitly chose to bind against —
// "feishu" (mainland, accounts.feishu.cn) or "lark" (international,
// accounts.larksuite.com). The frontend now exposes two CTAs ("Bind
// to Feishu" / "Bind to Lark") so the QR is rendered against the
// right cloud up front rather than relying on the mid-poll
// tenant-brand auto-switch from a Feishu-first begin. We accept
// "feishu", "lark", and the empty string (for back-compat with
// callers that pre-date the split CTA, which RegionOrDefault inside
// the service maps to Feishu); any other value is a 400 — the
// service would normalize an unknown value to Feishu silently and
// that would mask a frontend regression where a typo'd region
// landed users on the wrong cloud without telling them.
regionParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("region")))
switch regionParam {
case "", "feishu", "lark":
// ok — empty defaults to feishu downstream.
default:
writeError(w, http.StatusBadRequest, "region must be 'feishu' or 'lark'")
return
}
// Ownership pre-check at the HTTP boundary so a malformed
// agent_id surfaces 404 here (not an opaque service error from
// inside the service's own re-check).
@@ -284,6 +304,7 @@ func (h *Handler) BeginLarkInstall(w http.ResponseWriter, r *http.Request) {
WorkspaceID: wsUUID,
AgentID: agentUUID,
InitiatorID: initiatorUUID,
Region: lark.Region(regionParam),
})
if err != nil {
writeError(w, http.StatusBadGateway, "failed to start install: "+err.Error())

View File

@@ -72,6 +72,18 @@ const (
// tenant_brand hint exactly once during the polling stream and the
// subsequent poll must reach the new domain to learn the credentials.
registrationTenantBrandLark = "lark"
// Mirror brand label for the reverse direction: a user who picked
// the "Bind to Lark" CTA but actually authorized with a mainland
// Feishu account. The split-CTA UX (MUL-3083) rendered a QR against
// accounts.larksuite.com, but Lark's poll stream surfaces
// tenant_brand="feishu" once authorization completes on the wrong
// cloud, and we honor that signal symmetrically — re-aim polling
// at accounts.feishu.cn and let the next poll fetch the credentials
// from the right host. Without this, "wrong entry" was a hard
// install failure for the lark→feishu direction even though the
// feishu→lark direction recovered automatically.
registrationTenantBrandFeishu = "feishu"
)
// RegistrationConfig configures the device-flow client. All fields are
@@ -170,13 +182,23 @@ type PollResult struct {
ClientSecret string
OpenID OpenID
// SwitchedDomain is non-empty when Lark told us "this is a Lark
// international account, re-poll over there." RegistrationService
// must update its session's stored domain and re-poll WITHOUT
// honoring the interval (the SDK does the same — the upstream
// behaviour is that the very next poll lands on the new domain and
// returns the actual credentials).
// SwitchedDomain is non-empty when Lark told us "this is the wrong
// cloud, re-poll over there." It is paired with SwitchedRegion so
// the caller can update both the polling host AND the per-install
// region in one step. Originally this only fired in the
// Feishu→Lark direction (Lark international users authorizing on
// a Feishu-first begin); after MUL-3083 follow-up it is symmetric,
// so a user who picked the "wrong" Bind CTA also recovers — the
// service must update the session's stored domain AND region and
// re-poll WITHOUT honoring the interval (the SDK does the same —
// the upstream behaviour is that the very next poll lands on the
// new domain and returns the actual credentials).
SwitchedDomain string
// SwitchedRegion is the region the new domain belongs to. Set in
// lockstep with SwitchedDomain; ignored when SwitchedDomain is
// empty. Carrying the region here keeps the caller from having to
// re-derive it from the domain string at session-update time.
SwitchedRegion Region
// Status carries non-terminal protocol signals — typically
// "authorization_pending" or "slow_down". The service uses these
@@ -207,10 +229,18 @@ func (e *RegistrationError) Error() string {
return fmt.Sprintf("registration: %s: %s", e.Code, e.Description)
}
// Begin opens a new device-flow session against the configured Feishu
// (mainland) domain. Lark may surface a Lark-international tenant on
// the FIRST poll — we don't try to predict it here; the polling loop
// in RegistrationService handles the domain swap.
// Begin opens a new device-flow session against the open-platform host
// for the requested region. Region is normally chosen explicitly by the
// caller (the user picked "Feishu" or "Lark" in the UI) so the QR
// renders against the same cloud the user expects to scan from; an
// empty value falls back to Feishu (mainland) for back-compat with
// callers that pre-date region-aware install. Lark may STILL surface a
// Lark-international tenant on a subsequent poll even when the begin
// host was Feishu — the SwitchedDomain branch in RegistrationService
// keeps that auto-detect path alive as a fallback for users who pick
// the wrong entry, so explicit region selection is a routing
// optimization (saves one round-trip and renders the right cloud's QR
// up front), not a constraint on what the device flow can recover from.
//
// namePreset pre-fills the bot/app name on Lark's "create a
// PersonalAgent" form so the installed bot defaults to e.g.
@@ -218,7 +248,15 @@ func (e *RegistrationError) Error() string {
// "{用户姓名}的智能助手". It is a user-editable default (the user can
// still change it on the form), and it rides on the QR URL — not the
// begin POST body, which has no name field. Empty omits the pre-fill.
func (c *RegistrationClient) Begin(ctx context.Context, namePreset string) (*BeginResult, error) {
func (c *RegistrationClient) Begin(ctx context.Context, namePreset string, region Region) (*BeginResult, error) {
// Pick the begin domain off the requested region. Empty / unknown
// regions degrade to Feishu (mainland) — same back-compat invariant
// as RegionOrDefault, so callers that pre-date this signature
// (passing "") keep working.
domain := c.cfg.Domain
if region == RegionLark {
domain = c.cfg.LarkDomain
}
var resp struct {
DeviceCode string `json:"device_code"`
VerificationURIComplete string `json:"verification_uri_complete"`
@@ -235,7 +273,7 @@ func (c *RegistrationClient) Begin(ctx context.Context, namePreset string) (*Beg
"auth_method": []string{"client_secret"},
"request_user_info": []string{"open_id"},
}
if err := c.doForm(ctx, c.cfg.Domain, form, &resp); err != nil {
if err := c.doForm(ctx, domain, form, &resp); err != nil {
return nil, err
}
if resp.Error != "" {
@@ -262,7 +300,7 @@ func (c *RegistrationClient) Begin(ctx context.Context, namePreset string) (*Beg
return &BeginResult{
DeviceCode: resp.DeviceCode,
QRCodeURL: qr,
Domain: c.cfg.Domain,
Domain: domain,
Interval: time.Duration(interval) * time.Second,
ExpiresIn: time.Duration(expireIn) * time.Second,
}, nil
@@ -298,15 +336,41 @@ func (c *RegistrationClient) Poll(ctx context.Context, domain, deviceCode string
return nil, err
}
// Tenant-brand-driven domain swap. Lark emits this exactly once
// when a Lark-international account authorized; the next poll must
// hit accounts.larksuite.com to learn the credentials. We surface
// the swap as a typed signal so the service does not have to know
// the brand string.
if resp.UserInfo != nil &&
resp.UserInfo.TenantBrand == registrationTenantBrandLark &&
!strings.HasPrefix(domain, c.cfg.LarkDomain) {
return &PollResult{SwitchedDomain: c.cfg.LarkDomain}, nil
// Tenant-brand-driven domain swap. Lark emits this exactly once on
// the transition poll when the authorized account does not match
// the cloud the begin call hit; the next poll must reach the
// matching open-platform host to learn the credentials. We surface
// the swap (domain + region) as a typed signal so the service does
// not have to know the brand string OR re-derive the region from
// the host.
//
// Both directions are honored: feishu→lark for users who scanned a
// Feishu QR with a Lark-international account, AND lark→feishu for
// users who picked the new "Bind to Lark" CTA but actually
// authorized with a mainland Feishu account. Symmetry matters
// because the split-CTA UI (MUL-3083) also begins on
// accounts.larksuite.com directly — without the reverse swap, a
// "wrong entry" install on that side would carry RegionLark all
// the way through finishSuccess and fail (or commit a wrong-region
// row) at GetBotInfo. The check is gated on the current domain so
// we do not loop on the same brand we already match.
if resp.UserInfo != nil {
switch resp.UserInfo.TenantBrand {
case registrationTenantBrandLark:
if !strings.HasPrefix(domain, c.cfg.LarkDomain) {
return &PollResult{
SwitchedDomain: c.cfg.LarkDomain,
SwitchedRegion: RegionLark,
}, nil
}
case registrationTenantBrandFeishu:
if !strings.HasPrefix(domain, c.cfg.Domain) {
return &PollResult{
SwitchedDomain: c.cfg.Domain,
SwitchedRegion: RegionFeishu,
}, nil
}
}
}
// Success: both client_id AND client_secret AND the installer

View File

@@ -219,6 +219,13 @@ type registrationSession struct {
qrCodeURL string
interval time.Duration
expiresAt time.Time
// region is the cloud the install was started against. The polling
// loop reads it as the initial value of its `region` local; if the
// poll stream surfaces a tenant_brand mid-flow, the local flips to
// RegionLark, but the session field stays at what the user picked
// (it is informational — the authoritative cloud flows back through
// finishSuccess via the loop's local).
region Region
mu sync.Mutex
status RegistrationSessionStatus
@@ -281,6 +288,16 @@ type BeginInstallParams struct {
WorkspaceID pgtype.UUID
AgentID pgtype.UUID
InitiatorID pgtype.UUID
// Region picks which cloud's accounts host the device-flow begins
// against — Feishu (mainland, accounts.feishu.cn) or Lark
// (international, accounts.larksuite.com). The user picks this
// explicitly in the UI ("Bind to Feishu" vs "Bind to Lark") so the
// QR rendered up front already targets the right cloud and Lark
// users do not have to hit a Feishu URL first and rely on the
// tenant-brand auto-switch. Empty / unknown values fall back to
// Feishu, matching RegionOrDefault, so existing callers without
// the new field keep working.
Region Region
}
// BeginInstallResult is the public payload the handler echoes to the
@@ -323,7 +340,14 @@ func (s *RegistrationService) BeginInstall(ctx context.Context, p BeginInstallPa
return BeginInstallResult{}, fmt.Errorf("lark registration: agent not in workspace: %w", err)
}
begin, err := s.client.Begin(ctx, botNamePreset(agent.Name))
// Normalize the requested region: empty / unknown → Feishu, the same
// back-compat invariant the storage layer uses (RegionOrDefault).
// This both protects the device-flow client from a bogus value
// from the handler AND means a pre-region caller (omitting the
// field) keeps getting the historical mainland-first behaviour.
region := RegionOrDefault(string(p.Region))
begin, err := s.client.Begin(ctx, botNamePreset(agent.Name), region)
if err != nil {
return BeginInstallResult{}, fmt.Errorf("lark registration: begin: %w", err)
}
@@ -343,6 +367,7 @@ func (s *RegistrationService) BeginInstall(ctx context.Context, p BeginInstallPa
qrCodeURL: begin.QRCodeURL,
interval: begin.Interval,
expiresAt: now.Add(begin.ExpiresIn),
region: region,
status: RegistrationStatusPending,
}
s.mu.Lock()
@@ -408,13 +433,21 @@ 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
// whatever the user picked at begin-time (Feishu by default; the
// frontend now exposes an explicit Lark CTA that begins on
// accounts.larksuite.com directly). The SwitchedDomain branch
// below is still honored as a safety net — if a user clicks the
// Feishu CTA but actually authorizes with a Lark-international
// account, the poll stream surfaces tenant_brand="lark" and we
// flip the local accordingly. So at finishSuccess time `region`
// is the authoritative per-install cloud, derived first from the
// user's UI choice and then from the protocol's role-based switch
// — never by string-matching accounts hostnames (so staging/mock
// domains classify correctly too).
region := RegionFeishu
region := sess.region
if region == "" {
region = RegionFeishu
}
for {
select {
@@ -451,10 +484,18 @@ func (s *RegistrationService) runPolling(sess *registrationSession) {
// behavior. Lark emits the brand hint exactly once on the
// transition poll and the credential-bearing response
// lands on the next call to the new domain.
//
// Both directions are honored (feishu→lark and lark→feishu)
// so the split-CTA UI's "wrong entry" path recovers
// regardless of which CTA the user picked. The new region
// rides on the same PollResult so we never have to
// re-derive it from the host string here — staging / mock
// accounts hosts then classify correctly without
// hostname-prefix matching.
domain = res.SwitchedDomain
region = RegionLark
s.cfg.Logger.Info("lark registration: switched to lark-international domain",
"session_id", sess.id, "domain", domain)
region = res.SwitchedRegion
s.cfg.Logger.Info("lark registration: switched cloud after tenant-brand mismatch",
"session_id", sess.id, "domain", domain, "region", string(region))
continue
case res.ClientID != "" && res.ClientSecret != "":
s.finishSuccess(ctx, sess, res, region)

View File

@@ -81,7 +81,7 @@ func TestRegistrationClient_Begin_HappyPath(t *testing.T) {
})
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
res, err := c.Begin(context.Background(), "Ada - Multica")
res, err := c.Begin(context.Background(), "Ada - Multica", "")
if err != nil {
t.Fatalf("Begin: %v", err)
}
@@ -133,7 +133,7 @@ func TestRegistrationClient_Begin_OmitsNameWhenPresetEmpty(t *testing.T) {
})
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
res, err := c.Begin(context.Background(), "")
res, err := c.Begin(context.Background(), "", "")
if err != nil {
t.Fatalf("Begin: %v", err)
}
@@ -146,6 +146,83 @@ func TestRegistrationClient_Begin_OmitsNameWhenPresetEmpty(t *testing.T) {
}
}
// TestRegistrationClient_Begin_RegionLarkBeginsOnLarksuite pins the
// new explicit-region routing: passing region=lark to Begin must POST
// the begin form against the configured LarkDomain (international) host
// rather than the Feishu default. This is the routing optimization the
// split "Bind to Feishu / Bind to Lark" UI relies on — without it, a
// Lark user would still hit accounts.feishu.cn first and only flip to
// larksuite mid-poll via the tenant-brand auto-switch.
func TestRegistrationClient_Begin_RegionLarkBeginsOnLarksuite(t *testing.T) {
feishuFake := newRegistrationFake(t)
feishuFake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("region=lark should NOT POST begin to the Feishu host (%s)", feishuFake.URL())
w.WriteHeader(http.StatusInternalServerError)
})
larkFake := newRegistrationFake(t)
larkFake.stubBegin(map[string]any{
"device_code": "dc_lark",
"verification_uri_complete": "https://accounts.larksuite.com/oauth/v1/qrcode?code=abc",
})
c := NewRegistrationClient(RegistrationConfig{
Domain: feishuFake.URL(),
LarkDomain: larkFake.URL(),
})
res, err := c.Begin(context.Background(), "", RegionLark)
if err != nil {
t.Fatalf("Begin(region=lark): %v", err)
}
if res.Domain != larkFake.URL() {
t.Errorf("BeginResult.Domain: got %q want %q (LarkDomain) — subsequent polls must hit the larksuite host directly",
res.Domain, larkFake.URL())
}
if got := larkFake.beginN.Load(); got != 1 {
t.Errorf("Lark begin POSTs: got %d want 1", got)
}
if got := feishuFake.beginN.Load(); got != 0 {
t.Errorf("Feishu begin POSTs: got %d want 0 (region=lark must not touch Feishu host)", got)
}
}
// TestRegistrationClient_Begin_RegionFeishuBeginsOnFeishu pins the
// explicit-feishu side of the same split: passing region=feishu (or
// the empty-string back-compat default) keeps the original mainland
// host. Documenting both directions catches a future regression where
// the region selector accidentally inverts.
func TestRegistrationClient_Begin_RegionFeishuBeginsOnFeishu(t *testing.T) {
feishuFake := newRegistrationFake(t)
feishuFake.stubBegin(map[string]any{
"device_code": "dc_feishu",
"verification_uri_complete": "https://accounts.feishu.cn/oauth/v1/qrcode?code=abc",
})
larkFake := newRegistrationFake(t)
larkFake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("region=feishu should NOT POST begin to the Lark host (%s)", larkFake.URL())
w.WriteHeader(http.StatusInternalServerError)
})
c := NewRegistrationClient(RegistrationConfig{
Domain: feishuFake.URL(),
LarkDomain: larkFake.URL(),
})
res, err := c.Begin(context.Background(), "", RegionFeishu)
if err != nil {
t.Fatalf("Begin(region=feishu): %v", err)
}
if res.Domain != feishuFake.URL() {
t.Errorf("BeginResult.Domain: got %q want %q (Feishu)", res.Domain, feishuFake.URL())
}
if got := feishuFake.beginN.Load(); got != 1 {
t.Errorf("Feishu begin POSTs: got %d want 1", got)
}
if got := larkFake.beginN.Load(); got != 0 {
t.Errorf("Lark begin POSTs: got %d want 0", got)
}
}
func TestRegistrationClient_Begin_DefaultsWhenServerOmitsTimers(t *testing.T) {
// When Lark's response omits `interval` / `expire_in` (the empty
// path the upstream SDK accepts), the client falls back to its
@@ -159,7 +236,7 @@ func TestRegistrationClient_Begin_DefaultsWhenServerOmitsTimers(t *testing.T) {
})
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
res, err := c.Begin(context.Background(), "")
res, err := c.Begin(context.Background(), "", "")
if err != nil {
t.Fatalf("Begin: %v", err)
}
@@ -178,7 +255,7 @@ func TestRegistrationClient_Begin_LarkError(t *testing.T) {
"error_description": "missing archetype",
})
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
_, err := c.Begin(context.Background(), "")
_, err := c.Begin(context.Background(), "", "")
if err == nil {
t.Fatal("expected error from Lark error response")
}
@@ -198,7 +275,7 @@ func TestRegistrationClient_Begin_HTTPNon2xx(t *testing.T) {
_, _ = w.Write([]byte("server boom"))
})
c := NewRegistrationClient(RegistrationConfig{Domain: fake.URL()})
_, err := c.Begin(context.Background(), "")
_, err := c.Begin(context.Background(), "", "")
if err == nil {
t.Fatal("want error on 500")
}
@@ -357,6 +434,110 @@ func TestRegistrationClient_Poll_DomainSwitchOnLarkTenant(t *testing.T) {
if res.SwitchedDomain != "https://lark-international.test" {
t.Errorf("SwitchedDomain: got %q", res.SwitchedDomain)
}
if res.SwitchedRegion != RegionLark {
t.Errorf("SwitchedRegion: got %q want %q", res.SwitchedRegion, RegionLark)
}
}
// TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant pins the
// reverse direction of the tenant-brand swap: a session begun against
// the Lark international host whose authorizing account turns out to
// be on mainland Feishu must surface a switch back to Feishu, with
// the region flipping accordingly. Without this, a user who picks the
// "Bind to Lark" CTA but actually scans with a Feishu account would
// carry RegionLark all the way through finishSuccess and either fail
// at GetBotInfo or commit a wrong-region installation row. Documenting
// this side keeps the symmetry promised in the public PollResult docs
// and the split-CTA UI's "wrong entry" recovery contract.
func TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant(t *testing.T) {
larkFake := newRegistrationFake(t)
larkFake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{
"user_info": map[string]any{"tenant_brand": "feishu"},
})
})
// Domain (Feishu) points at a *distinct* host so we can assert the
// switch landed there; LarkDomain is the host we are CURRENTLY on
// (the larkFake) so the swap predicate (`!HasPrefix(domain, LarkDomain)`)
// resolves correctly.
c := NewRegistrationClient(RegistrationConfig{
Domain: "https://feishu-mainland.test",
LarkDomain: larkFake.URL(),
})
res, err := c.Poll(context.Background(), larkFake.URL(), "dc_x")
if err != nil {
t.Fatalf("Poll: %v", err)
}
if res.SwitchedDomain != "https://feishu-mainland.test" {
t.Errorf("SwitchedDomain: got %q want feishu host", res.SwitchedDomain)
}
if res.SwitchedRegion != RegionFeishu {
t.Errorf("SwitchedRegion: got %q want %q", res.SwitchedRegion, RegionFeishu)
}
}
// TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost pins
// that the swap is gated on the current domain — a `tenant_brand=lark`
// hint emitted while polling AGAINST the Lark host must NOT fire a
// redundant switch (which would loop the polling state machine), and
// likewise `tenant_brand=feishu` against the Feishu host. Both arms
// of the symmetry are covered to catch a future regression where the
// gate flips on only one side.
func TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost(t *testing.T) {
cases := []struct {
name string
brand string
begunOn string
feishuHost string
larkHost string
}{
{
name: "lark brand on lark host is a no-op",
brand: "lark",
begunOn: "https://lark-international.test",
feishuHost: "https://feishu-mainland.test",
larkHost: "https://lark-international.test",
},
{
name: "feishu brand on feishu host is a no-op",
brand: "feishu",
begunOn: "https://feishu-mainland.test",
feishuHost: "https://feishu-mainland.test",
larkHost: "https://lark-international.test",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := newRegistrationFake(t)
fake.mux.HandleFunc(registrationEndpoint, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{
"user_info": map[string]any{"tenant_brand": tc.brand},
})
})
// Use the fake's URL for whichever side we claim to be on,
// and a placeholder URL for the other — the test only
// exercises a single Poll call, the cross-host re-poll is
// the service's job.
cfg := RegistrationConfig{Domain: tc.feishuHost, LarkDomain: tc.larkHost}
if tc.begunOn == tc.feishuHost {
cfg.Domain = fake.URL()
} else {
cfg.LarkDomain = fake.URL()
}
c := NewRegistrationClient(cfg)
res, err := c.Poll(context.Background(), fake.URL(), "dc_x")
if err != nil {
t.Fatalf("Poll: %v", err)
}
if res.SwitchedDomain != "" {
t.Errorf("SwitchedDomain: got %q, want empty (already on matching host)",
res.SwitchedDomain)
}
if res.SwitchedRegion != "" {
t.Errorf("SwitchedRegion: got %q, want empty", res.SwitchedRegion)
}
})
}
}
func TestRegistrationClient_Poll_Success(t *testing.T) {