Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,10 @@ MPA 智能体详情当前仅显示基本信息。其他页面及 Profile 配置

### MPA Feishu multi-bot accounts / MPA 飞书多机器人

When Runtime advertises `botBindingTypes`, adding a Feishu or Lark Bot offers Shared/Private (Shared by default). QR, retry and manual binding send the selected `bindingType`; older Runtime payloads remain unchanged. The choice is locked during binding and is not an existing-account editing control. Private: “Private chats can recall this Bot’s history, including group chats.” Runtime still enforces recall authorization and isolation.

Runtime 声明 `botBindingTypes` 时,添加飞书或 Lark Bot 可选择共享/私有(默认共享);扫码、重试和手动绑定携带所选 `bindingType`,旧 Runtime 请求不变。绑定过程中锁定类型,不提供已有账号类型编辑。私有说明:“私聊可召回同 Bot 的历史,包括群聊。”召回鉴权和隔离仍由 Runtime 执行。

Runtimes advertising `multiBotChannels: ["feishu"]` expose a robot selector in
Automations → Messaging channels → MPA agent messaging channels. Add bots with
QR or manual binding, then manage enablement, unbinding, diagnostics and group
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/adk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6596,7 +6596,8 @@ export async function updateSandboxTool(kind: SandboxToolKind): Promise<{


export type ChannelScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
export interface ChannelCapabilities { feishuDomains?: string[]; channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; multiBotChannels?: string[]; accountScopedPermissions?: boolean; }
export type BotBindingType = "shared" | "private";
export interface ChannelCapabilities { botBindingTypes?: BotBindingType[]; feishuDomains?: string[]; channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; multiBotChannels?: string[]; accountScopedPermissions?: boolean; }
export interface FeishuBinding {
id: string;
status: "PENDING" | "SCANNED" | "AUTHORIZED" | "REGISTERING" | "BOUND" | "FAILED" | "EXPIRED";
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/resources/en-US/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1968,6 +1968,11 @@
}
},
"channels": {
"bindingType": "Bot type",
"bindingType_shared": "Shared",
"bindingType_private": "Private",
"bindingTypeHelp_shared": "Sessions are isolated from each other.",
"bindingTypeHelp_private": "Private chats can recall this Bot’s history, including group chats.",
"title": "Feishu channel",
"refresh": "Refresh",
"loading": "Loading channel configuration…",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/resources/zh-CN/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1968,6 +1968,11 @@
}
},
"channels": {
"bindingType": "Bot 类型",
"bindingType_shared": "共享",
"bindingType_private": "私有",
"bindingTypeHelp_shared": "各会话相互隔离。",
"bindingTypeHelp_private": "私聊可召回同 Bot 的历史,包括群聊。",
"title": "飞书渠道",
"refresh": "刷新",
"loading": "正在加载渠道配置…",
Expand Down
40 changes: 39 additions & 1 deletion frontend/src/ui/RuntimeChannels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Radio } from "../components/primitives/Radio/Radio";
import {
channelRequest,
ChannelApiError,
type BotBindingType,
type ChannelCapabilities,
type ChannelDiagnostics,
type ChannelPermission,
Expand Down Expand Up @@ -164,6 +165,17 @@ function ProviderChannel({
null,
);
const [groups, setGroups] = useState<ChannelPermission[]>([]);
const [selectedBindingType, setSelectedBindingType] =
useState<BotBindingType>("shared");
const bindingTypes = (["shared", "private"] as const).filter(
(value) =>
isFeishu &&
Array.isArray(capability?.botBindingTypes) &&
capability.botBindingTypes.includes(value),
);
const bindingType =
bindingTypes.find((value) => value === selectedBindingType) ??
bindingTypes[0];
const [pairingRequested, setPairingRequested] = useState(false);
const [binding, setBinding] = useState<FeishuBinding | null>(null);
const [botId, setBotId] = useState("");
Expand Down Expand Up @@ -363,7 +375,10 @@ function ProviderChannel({
: `/${apiProvider}/bindings`,
{
method: "POST",
body: provider === "lark" ? JSON.stringify({ domain }) : "{}",
body: JSON.stringify({
...(provider === "lark" ? { domain } : {}),
...(bindingType ? { bindingType } : {}),
}),
signal,
},
);
Expand Down Expand Up @@ -596,6 +611,28 @@ function ProviderChannel({
)}
{accountMode !== "manage" && (
<>
{bindingType && (
<fieldset className="runtime-channels-methods">
<legend>{t("channels.bindingType")}</legend>
<div>
{bindingTypes.map((value) => (
<Radio
key={value}
name={`${methodId}-binding-type`}
value={value}
checked={bindingType === value}
disabled={credentialDisabled || !!binding}
aria-describedby={`${methodId}-binding-type-help`}
label={t(`channels.bindingType_${value}`)}
onChange={() => setSelectedBindingType(value)}
/>
))}
</div>
<p id={`${methodId}-binding-type-help`}>
{t(`channels.bindingTypeHelp_${bindingType}`)}
</p>
</fieldset>
)}
<fieldset className="runtime-channels-methods">
<legend>{t("channels.configurationMethod")}</legend>
<div>
Expand Down Expand Up @@ -667,6 +704,7 @@ function ProviderChannel({
? {
appId: botId.trim(),
appSecret: secret.trim(),
...(bindingType ? { bindingType } : {}),
...(provider === "lark" ? { domain } : {}),
}
: {
Expand Down
236 changes: 236 additions & 0 deletions frontend/tests/runtimeChannels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1551,3 +1551,239 @@ it("blocks Lark IME and duplicate writes and aborts on brand switch", async () =
expect(host.textContent).not.toContain("lark-new");
expect(host.textContent).toContain("channels.accountsTitle");
});

function bindingTypesFixture(
types: unknown = ["shared", "private"],
lark = false,
) {
if (lark) larkFixture();
else multiBotFixture();
const original = request.getMockImplementation()!;
request.mockImplementation(async (ep, path, init) => {
if (path === "/capabilities")
return { ...(await original(ep, path, init)), botBindingTypes: types };
if (path === "/feishu/bindings") return pending;
if (path.endsWith("/retry")) return { ...pending, status: "BOUND", appId: "bot-new" };
return original(ep, path, init);
});
}
const typeInput = (value: string) =>
host.querySelector<HTMLInputElement>(`input[type="radio"][value="${value}"]`);
async function chooseType(value: string) {
expect(typeInput(value)).not.toBeNull();
await act(async () => typeInput(value)!.click());
}
async function submitCredentials() {
await selectMethod("manual");
await fillCredential("appId", " bot-new ");
await fillCredential("secret", "fixture-secret");
await act(async () =>
host
.querySelector("form[data-channel-binding]")!
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })),
);
}
function lastBody(path: string) {
const calls = request.mock.calls.filter(
([, value, init]) => value === path && init?.method === "POST",
);
expect(calls.length).toBeGreaterThan(0);
return JSON.parse(String(calls.at(-1)![2]!.body));
}

it.each(["shared", "private"])(
"sends the selected %s type for QR and manual binding",
async (type) => {
bindingTypesFixture();
await render();
await click("channels.addAccount");
expect(typeInput("shared")?.checked).toBe(true);
await chooseType(type);
expect(host.textContent).toContain(`channels.bindingTypeHelp_${type}`);
await click("channels.bind");
expect(lastBody("/feishu/bindings")).toEqual({ bindingType: type });
expect(typeInput("shared")?.disabled).toBe(true);
expect(typeInput("private")?.disabled).toBe(true);
await submitCredentials();
expect(lastBody("/feishu/bindings/manual")).toEqual({
appId: "bot-new",
appSecret: "fixture-secret",
bindingType: type,
});
expect(localStorage.setItem).not.toHaveBeenCalled();
},
);

it.each([undefined, [], ["future"], null, "private"])(
"keeps legacy payloads when types are unsupported: %j",
async (types) => {
bindingTypesFixture(types === undefined ? [] : types);
if (types === undefined) {
const original = request.getMockImplementation()!;
request.mockImplementation(async (ep, path, init) => {
const data = await original(ep, path, init);
if (path === "/capabilities") delete data.botBindingTypes;
return data;
});
}
await render();
await click("channels.addAccount");
expect(typeInput("private")).toBeNull();
expect(typeInput("shared")).toBeNull();
await click("channels.bind");
expect(lastBody("/feishu/bindings")).toEqual({});
await submitCredentials();
expect(lastBody("/feishu/bindings/manual")).toEqual({
appId: "bot-new",
appSecret: "fixture-secret",
});
},
);

it.each([["private"], ["future", "private"], ["private", "private"]])(
"only offers advertised known types: %j",
async (...types) => {
bindingTypesFixture(types);
await render();
await click("channels.addAccount");
expect(typeInput("shared")).toBeNull();
expect(typeInput("private")?.checked).toBe(true);
expect(host.querySelectorAll('input[value="private"]')).toHaveLength(1);
await click("channels.bind");
expect(lastBody("/feishu/bindings")).toEqual({ bindingType: "private" });
},
);

it("preserves private type when retrying a durable failed QR binding", async () => {
bindingTypesFixture();
const original = request.getMockImplementation()!;
request.mockImplementation(async (ep, path, init) =>
path === "/feishu/bindings"
? { ...pending, status: "FAILED", lastErrorCode: "REGISTRATION_FAILED" }
: original(ep, path, init),
);
await render();
await click("channels.addAccount");
await chooseType("private");
await click("channels.bind");
expect(typeInput("shared")?.disabled).toBe(true);
await chooseType("shared");
expect(typeInput("private")?.checked).toBe(true);
await click("common.retry");
expect(lastBody("/feishu/bindings/binding-1/retry")).toEqual({
bindingType: "private",
});
});

it("locks selection while sending and preserves it after a recoverable error", async () => {
bindingTypesFixture();
const original = request.getMockImplementation()!;
let reject!: (reason: unknown) => void;
request.mockImplementation((ep, path, init) =>
path === "/feishu/bindings"
? new Promise((_resolve, fail) => {
reject = fail;
})
: original(ep, path, init),
);
await render();
await click("channels.addAccount");
await chooseType("private");
await click("channels.bind");
expect(typeInput("shared")?.disabled).toBe(true);
await act(async () => reject(new ChannelApiError(502)));
expect(host.textContent).toContain("channels.requestFailed");
expect(typeInput("private")?.checked).toBe(true);
expect(typeInput("shared")?.disabled).toBe(false);
});

it("resets type when cancelling Add bot or changing Runtime and ignores late capabilities", async () => {
bindingTypesFixture();
await render();
await click("channels.addAccount");
await chooseType("private");
await click("channels.cancelAddAccount");
await click("channels.addAccount");
expect(typeInput("shared")?.checked).toBe(true);
await chooseType("private");
const original = request.getMockImplementation()!;
let finish!: (value: unknown) => void;
let signal: AbortSignal | null | undefined;
request.mockImplementation((ep, path, init) =>
ep.runtimeId === "runtime-slow" && path === "/capabilities"
? new Promise((resolve) => {
finish = resolve;
signal = init?.signal;
})
: original(ep, path, init),
);
await act(async () =>
root.render(
<RuntimeChannels runtimeId="runtime-slow" region="cn-beijing" />,
),
);
expect(typeInput("private")).toBeNull();
await act(async () =>
root.render(
<RuntimeChannels runtimeId="runtime-new" region="cn-beijing" />,
),
);
expect(signal?.aborted).toBe(true);
await act(async () =>
finish({
serverSideBinding: true,
bindingReady: true,
botBindingTypes: ["private"],
}),
);
await click("channels.addAccount");
expect(typeInput("shared")?.checked).toBe(true);
expect(
request.mock.calls.filter(([, , init]) => init?.method === "POST"),
).toHaveLength(0);
});

it("does not expose an editable type for existing accounts or other providers", async () => {
bindingTypesFixture();
await render();
await selectBot("bot-a");
expect(typeInput("private")).toBeNull();
await click("channels.provider_wecom");
expect(typeInput("private")).toBeNull();
await click("channels.provider_dingtalk");
expect(typeInput("private")).toBeNull();
});

it("retains Lark domain alongside private type in QR, retry and manual requests", async () => {
bindingTypesFixture(["shared", "private"], true);
const original = request.getMockImplementation()!;
request.mockImplementation(async (ep, path, init) =>
path === "/feishu/bindings"
? { ...pending, status: "FAILED", lastErrorCode: "REGISTRATION_FAILED" }
: original(ep, path, init),
);
await renderLark();
await click("channels.addAccount");
await chooseType("private");
await click("channels.bind");
expect(lastBody("/feishu/bindings")).toEqual({
domain: "lark",
bindingType: "private",
});
await click("common.retry");
expect(lastBody("/feishu/bindings/binding-1/retry")).toEqual({
domain: "lark",
bindingType: "private",
});
// The successful retry closes the add panel; a new binding chooses its own type.
await click("channels.addAccount");
expect(typeInput("shared")?.checked).toBe(true);
await chooseType("private");
await submitCredentials();
expect(lastBody("/feishu/bindings/manual")).toEqual({
appId: "bot-new",
appSecret: "fixture-secret",
domain: "lark",
bindingType: "private",
});
});
Loading
Loading