mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 16:09:19 +02:00
Compare commits
3 Commits
fix/skill-
...
j/lark-bin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d368213e | ||
|
|
51dc62b155 | ||
|
|
999dd13fc7 |
221
apps/desktop/src/main/context-menu.test.ts
Normal file
221
apps/desktop/src/main/context-menu.test.ts
Normal 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?.();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "設置状況の確認中に一時的にネットワークが途切れました — 再試行します。",
|
||||
|
||||
@@ -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": "설치 상태를 확인하는 중 네트워크가 일시적으로 끊겼어요 — 다시 시도합니다.",
|
||||
|
||||
@@ -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": "网络抖动,正在重试查询安装状态。",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user