Skip to content

Commit ba2cd7a

Browse files
feat(studio): select private or shared Bot bindings (#1171)
Co-authored-by: chenzhenghui <chenzhenghui@bytedance.com>
1 parent c4ff2a6 commit ba2cd7a

78 files changed

Lines changed: 736 additions & 335 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎frontend/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,6 +1856,10 @@ MPA 智能体详情当前仅显示基本信息。其他页面及 Profile 配置
18561856

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

1859+
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.
1860+
1861+
Runtime 声明 `botBindingTypes` 时,添加飞书或 Lark Bot 可选择共享/私有(默认共享);扫码、重试和手动绑定携带所选 `bindingType`,旧 Runtime 请求不变。绑定过程中锁定类型,不提供已有账号类型编辑。私有说明:“私聊可召回同 Bot 的历史,包括群聊。”召回鉴权和隔离仍由 Runtime 执行。
1862+
18591863
Runtimes advertising `multiBotChannels: ["feishu"]` expose a robot selector in
18601864
Automations → Messaging channels → MPA agent messaging channels. Add bots with
18611865
QR or manual binding, then manage enablement, unbinding, diagnostics and group

‎frontend/src/adk/client.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6596,7 +6596,8 @@ export async function updateSandboxTool(kind: SandboxToolKind): Promise<{
65966596

65976597

65986598
export type ChannelScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
6599-
export interface ChannelCapabilities { feishuDomains?: string[]; channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; multiBotChannels?: string[]; accountScopedPermissions?: boolean; }
6599+
export type BotBindingType = "shared" | "private";
6600+
export interface ChannelCapabilities { botBindingTypes?: BotBindingType[]; feishuDomains?: string[]; channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; multiBotChannels?: string[]; accountScopedPermissions?: boolean; }
66006601
export interface FeishuBinding {
66016602
id: string;
66026603
status: "PENDING" | "SCANNED" | "AUTHORIZED" | "REGISTERING" | "BOUND" | "FAILED" | "EXPIRED";

‎frontend/src/i18n/resources/en-US/ui.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,6 +1968,11 @@
19681968
}
19691969
},
19701970
"channels": {
1971+
"bindingType": "Bot type",
1972+
"bindingType_shared": "Shared",
1973+
"bindingType_private": "Private",
1974+
"bindingTypeHelp_shared": "Sessions are isolated from each other.",
1975+
"bindingTypeHelp_private": "Private chats can recall this Bot’s history, including group chats.",
19711976
"title": "Feishu channel",
19721977
"refresh": "Refresh",
19731978
"loading": "Loading channel configuration…",

‎frontend/src/i18n/resources/zh-CN/ui.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,6 +1968,11 @@
19681968
}
19691969
},
19701970
"channels": {
1971+
"bindingType": "Bot 类型",
1972+
"bindingType_shared": "共享",
1973+
"bindingType_private": "私有",
1974+
"bindingTypeHelp_shared": "各会话相互隔离。",
1975+
"bindingTypeHelp_private": "私聊可召回同 Bot 的历史,包括群聊。",
19711976
"title": "飞书渠道",
19721977
"refresh": "刷新",
19731978
"loading": "正在加载渠道配置…",

‎frontend/src/ui/RuntimeChannels.tsx‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Radio } from "../components/primitives/Radio/Radio";
66
import {
77
channelRequest,
88
ChannelApiError,
9+
type BotBindingType,
910
type ChannelCapabilities,
1011
type ChannelDiagnostics,
1112
type ChannelPermission,
@@ -164,6 +165,17 @@ function ProviderChannel({
164165
null,
165166
);
166167
const [groups, setGroups] = useState<ChannelPermission[]>([]);
168+
const [selectedBindingType, setSelectedBindingType] =
169+
useState<BotBindingType>("shared");
170+
const bindingTypes = (["shared", "private"] as const).filter(
171+
(value) =>
172+
isFeishu &&
173+
Array.isArray(capability?.botBindingTypes) &&
174+
capability.botBindingTypes.includes(value),
175+
);
176+
const bindingType =
177+
bindingTypes.find((value) => value === selectedBindingType) ??
178+
bindingTypes[0];
167179
const [pairingRequested, setPairingRequested] = useState(false);
168180
const [binding, setBinding] = useState<FeishuBinding | null>(null);
169181
const [botId, setBotId] = useState("");
@@ -363,7 +375,10 @@ function ProviderChannel({
363375
: `/${apiProvider}/bindings`,
364376
{
365377
method: "POST",
366-
body: provider === "lark" ? JSON.stringify({ domain }) : "{}",
378+
body: JSON.stringify({
379+
...(provider === "lark" ? { domain } : {}),
380+
...(bindingType ? { bindingType } : {}),
381+
}),
367382
signal,
368383
},
369384
);
@@ -596,6 +611,28 @@ function ProviderChannel({
596611
)}
597612
{accountMode !== "manage" && (
598613
<>
614+
{bindingType && (
615+
<fieldset className="runtime-channels-methods">
616+
<legend>{t("channels.bindingType")}</legend>
617+
<div>
618+
{bindingTypes.map((value) => (
619+
<Radio
620+
key={value}
621+
name={`${methodId}-binding-type`}
622+
value={value}
623+
checked={bindingType === value}
624+
disabled={credentialDisabled || !!binding}
625+
aria-describedby={`${methodId}-binding-type-help`}
626+
label={t(`channels.bindingType_${value}`)}
627+
onChange={() => setSelectedBindingType(value)}
628+
/>
629+
))}
630+
</div>
631+
<p id={`${methodId}-binding-type-help`}>
632+
{t(`channels.bindingTypeHelp_${bindingType}`)}
633+
</p>
634+
</fieldset>
635+
)}
599636
<fieldset className="runtime-channels-methods">
600637
<legend>{t("channels.configurationMethod")}</legend>
601638
<div>
@@ -667,6 +704,7 @@ function ProviderChannel({
667704
? {
668705
appId: botId.trim(),
669706
appSecret: secret.trim(),
707+
...(bindingType ? { bindingType } : {}),
670708
...(provider === "lark" ? { domain } : {}),
671709
}
672710
: {

‎frontend/tests/runtimeChannels.test.tsx‎

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,3 +1551,239 @@ it("blocks Lark IME and duplicate writes and aborts on brand switch", async () =
15511551
expect(host.textContent).not.toContain("lark-new");
15521552
expect(host.textContent).toContain("channels.accountsTitle");
15531553
});
1554+
1555+
function bindingTypesFixture(
1556+
types: unknown = ["shared", "private"],
1557+
lark = false,
1558+
) {
1559+
if (lark) larkFixture();
1560+
else multiBotFixture();
1561+
const original = request.getMockImplementation()!;
1562+
request.mockImplementation(async (ep, path, init) => {
1563+
if (path === "/capabilities")
1564+
return { ...(await original(ep, path, init)), botBindingTypes: types };
1565+
if (path === "/feishu/bindings") return pending;
1566+
if (path.endsWith("/retry")) return { ...pending, status: "BOUND", appId: "bot-new" };
1567+
return original(ep, path, init);
1568+
});
1569+
}
1570+
const typeInput = (value: string) =>
1571+
host.querySelector<HTMLInputElement>(`input[type="radio"][value="${value}"]`);
1572+
async function chooseType(value: string) {
1573+
expect(typeInput(value)).not.toBeNull();
1574+
await act(async () => typeInput(value)!.click());
1575+
}
1576+
async function submitCredentials() {
1577+
await selectMethod("manual");
1578+
await fillCredential("appId", " bot-new ");
1579+
await fillCredential("secret", "fixture-secret");
1580+
await act(async () =>
1581+
host
1582+
.querySelector("form[data-channel-binding]")!
1583+
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })),
1584+
);
1585+
}
1586+
function lastBody(path: string) {
1587+
const calls = request.mock.calls.filter(
1588+
([, value, init]) => value === path && init?.method === "POST",
1589+
);
1590+
expect(calls.length).toBeGreaterThan(0);
1591+
return JSON.parse(String(calls.at(-1)![2]!.body));
1592+
}
1593+
1594+
it.each(["shared", "private"])(
1595+
"sends the selected %s type for QR and manual binding",
1596+
async (type) => {
1597+
bindingTypesFixture();
1598+
await render();
1599+
await click("channels.addAccount");
1600+
expect(typeInput("shared")?.checked).toBe(true);
1601+
await chooseType(type);
1602+
expect(host.textContent).toContain(`channels.bindingTypeHelp_${type}`);
1603+
await click("channels.bind");
1604+
expect(lastBody("/feishu/bindings")).toEqual({ bindingType: type });
1605+
expect(typeInput("shared")?.disabled).toBe(true);
1606+
expect(typeInput("private")?.disabled).toBe(true);
1607+
await submitCredentials();
1608+
expect(lastBody("/feishu/bindings/manual")).toEqual({
1609+
appId: "bot-new",
1610+
appSecret: "fixture-secret",
1611+
bindingType: type,
1612+
});
1613+
expect(localStorage.setItem).not.toHaveBeenCalled();
1614+
},
1615+
);
1616+
1617+
it.each([undefined, [], ["future"], null, "private"])(
1618+
"keeps legacy payloads when types are unsupported: %j",
1619+
async (types) => {
1620+
bindingTypesFixture(types === undefined ? [] : types);
1621+
if (types === undefined) {
1622+
const original = request.getMockImplementation()!;
1623+
request.mockImplementation(async (ep, path, init) => {
1624+
const data = await original(ep, path, init);
1625+
if (path === "/capabilities") delete data.botBindingTypes;
1626+
return data;
1627+
});
1628+
}
1629+
await render();
1630+
await click("channels.addAccount");
1631+
expect(typeInput("private")).toBeNull();
1632+
expect(typeInput("shared")).toBeNull();
1633+
await click("channels.bind");
1634+
expect(lastBody("/feishu/bindings")).toEqual({});
1635+
await submitCredentials();
1636+
expect(lastBody("/feishu/bindings/manual")).toEqual({
1637+
appId: "bot-new",
1638+
appSecret: "fixture-secret",
1639+
});
1640+
},
1641+
);
1642+
1643+
it.each([["private"], ["future", "private"], ["private", "private"]])(
1644+
"only offers advertised known types: %j",
1645+
async (...types) => {
1646+
bindingTypesFixture(types);
1647+
await render();
1648+
await click("channels.addAccount");
1649+
expect(typeInput("shared")).toBeNull();
1650+
expect(typeInput("private")?.checked).toBe(true);
1651+
expect(host.querySelectorAll('input[value="private"]')).toHaveLength(1);
1652+
await click("channels.bind");
1653+
expect(lastBody("/feishu/bindings")).toEqual({ bindingType: "private" });
1654+
},
1655+
);
1656+
1657+
it("preserves private type when retrying a durable failed QR binding", async () => {
1658+
bindingTypesFixture();
1659+
const original = request.getMockImplementation()!;
1660+
request.mockImplementation(async (ep, path, init) =>
1661+
path === "/feishu/bindings"
1662+
? { ...pending, status: "FAILED", lastErrorCode: "REGISTRATION_FAILED" }
1663+
: original(ep, path, init),
1664+
);
1665+
await render();
1666+
await click("channels.addAccount");
1667+
await chooseType("private");
1668+
await click("channels.bind");
1669+
expect(typeInput("shared")?.disabled).toBe(true);
1670+
await chooseType("shared");
1671+
expect(typeInput("private")?.checked).toBe(true);
1672+
await click("common.retry");
1673+
expect(lastBody("/feishu/bindings/binding-1/retry")).toEqual({
1674+
bindingType: "private",
1675+
});
1676+
});
1677+
1678+
it("locks selection while sending and preserves it after a recoverable error", async () => {
1679+
bindingTypesFixture();
1680+
const original = request.getMockImplementation()!;
1681+
let reject!: (reason: unknown) => void;
1682+
request.mockImplementation((ep, path, init) =>
1683+
path === "/feishu/bindings"
1684+
? new Promise((_resolve, fail) => {
1685+
reject = fail;
1686+
})
1687+
: original(ep, path, init),
1688+
);
1689+
await render();
1690+
await click("channels.addAccount");
1691+
await chooseType("private");
1692+
await click("channels.bind");
1693+
expect(typeInput("shared")?.disabled).toBe(true);
1694+
await act(async () => reject(new ChannelApiError(502)));
1695+
expect(host.textContent).toContain("channels.requestFailed");
1696+
expect(typeInput("private")?.checked).toBe(true);
1697+
expect(typeInput("shared")?.disabled).toBe(false);
1698+
});
1699+
1700+
it("resets type when cancelling Add bot or changing Runtime and ignores late capabilities", async () => {
1701+
bindingTypesFixture();
1702+
await render();
1703+
await click("channels.addAccount");
1704+
await chooseType("private");
1705+
await click("channels.cancelAddAccount");
1706+
await click("channels.addAccount");
1707+
expect(typeInput("shared")?.checked).toBe(true);
1708+
await chooseType("private");
1709+
const original = request.getMockImplementation()!;
1710+
let finish!: (value: unknown) => void;
1711+
let signal: AbortSignal | null | undefined;
1712+
request.mockImplementation((ep, path, init) =>
1713+
ep.runtimeId === "runtime-slow" && path === "/capabilities"
1714+
? new Promise((resolve) => {
1715+
finish = resolve;
1716+
signal = init?.signal;
1717+
})
1718+
: original(ep, path, init),
1719+
);
1720+
await act(async () =>
1721+
root.render(
1722+
<RuntimeChannels runtimeId="runtime-slow" region="cn-beijing" />,
1723+
),
1724+
);
1725+
expect(typeInput("private")).toBeNull();
1726+
await act(async () =>
1727+
root.render(
1728+
<RuntimeChannels runtimeId="runtime-new" region="cn-beijing" />,
1729+
),
1730+
);
1731+
expect(signal?.aborted).toBe(true);
1732+
await act(async () =>
1733+
finish({
1734+
serverSideBinding: true,
1735+
bindingReady: true,
1736+
botBindingTypes: ["private"],
1737+
}),
1738+
);
1739+
await click("channels.addAccount");
1740+
expect(typeInput("shared")?.checked).toBe(true);
1741+
expect(
1742+
request.mock.calls.filter(([, , init]) => init?.method === "POST"),
1743+
).toHaveLength(0);
1744+
});
1745+
1746+
it("does not expose an editable type for existing accounts or other providers", async () => {
1747+
bindingTypesFixture();
1748+
await render();
1749+
await selectBot("bot-a");
1750+
expect(typeInput("private")).toBeNull();
1751+
await click("channels.provider_wecom");
1752+
expect(typeInput("private")).toBeNull();
1753+
await click("channels.provider_dingtalk");
1754+
expect(typeInput("private")).toBeNull();
1755+
});
1756+
1757+
it("retains Lark domain alongside private type in QR, retry and manual requests", async () => {
1758+
bindingTypesFixture(["shared", "private"], true);
1759+
const original = request.getMockImplementation()!;
1760+
request.mockImplementation(async (ep, path, init) =>
1761+
path === "/feishu/bindings"
1762+
? { ...pending, status: "FAILED", lastErrorCode: "REGISTRATION_FAILED" }
1763+
: original(ep, path, init),
1764+
);
1765+
await renderLark();
1766+
await click("channels.addAccount");
1767+
await chooseType("private");
1768+
await click("channels.bind");
1769+
expect(lastBody("/feishu/bindings")).toEqual({
1770+
domain: "lark",
1771+
bindingType: "private",
1772+
});
1773+
await click("common.retry");
1774+
expect(lastBody("/feishu/bindings/binding-1/retry")).toEqual({
1775+
domain: "lark",
1776+
bindingType: "private",
1777+
});
1778+
// The successful retry closes the add panel; a new binding chooses its own type.
1779+
await click("channels.addAccount");
1780+
expect(typeInput("shared")?.checked).toBe(true);
1781+
await chooseType("private");
1782+
await submitCredentials();
1783+
expect(lastBody("/feishu/bindings/manual")).toEqual({
1784+
appId: "bot-new",
1785+
appSecret: "fixture-secret",
1786+
domain: "lark",
1787+
bindingType: "private",
1788+
});
1789+
});

0 commit comments

Comments
 (0)