mirror of https://github.com/openclaw/openclaw.git
feat(feishu): add current-conversation session binding
This commit is contained in:
parent
76c9094c8d
commit
e9939be6ba
|
|
@ -0,0 +1,68 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./src/docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/chat.js", () => ({
|
||||
registerFeishuChatTools: registerFeishuChatToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/wiki.js", () => ({
|
||||
registerFeishuWikiTools: registerFeishuWikiToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/drive.js", () => ({
|
||||
registerFeishuDriveTools: registerFeishuDriveToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/perm.js", () => ({
|
||||
registerFeishuPermTools: registerFeishuPermToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/bitable.js", () => ({
|
||||
registerFeishuBitableTools: registerFeishuBitableToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/runtime.js", () => ({
|
||||
setFeishuRuntime: setFeishuRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/subagent-hooks.js", () => ({
|
||||
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
|
||||
}));
|
||||
|
||||
describe("feishu plugin register", () => {
|
||||
it("registers the Feishu channel, tools, and subagent hooks", async () => {
|
||||
const { default: plugin } = await import("./index.js");
|
||||
const registerChannel = vi.fn();
|
||||
const api = {
|
||||
runtime: { log: vi.fn() },
|
||||
registerChannel,
|
||||
on: vi.fn(),
|
||||
config: {},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { registerFeishuDocTools } from "./src/docx.js";
|
|||
import { registerFeishuDriveTools } from "./src/drive.js";
|
||||
import { registerFeishuPermTools } from "./src/perm.js";
|
||||
import { setFeishuRuntime } from "./src/runtime.js";
|
||||
import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js";
|
||||
import { registerFeishuWikiTools } from "./src/wiki.js";
|
||||
|
||||
export { monitorFeishuProvider } from "./src/monitor.js";
|
||||
|
|
@ -53,6 +54,7 @@ const plugin = {
|
|||
register(api: OpenClawPluginApi) {
|
||||
setFeishuRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin });
|
||||
registerFeishuSubagentHooks(api);
|
||||
registerFeishuDocTools(api);
|
||||
registerFeishuChatTools(api);
|
||||
registerFeishuWikiTools(api);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,623 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerFeishuSubagentHooks } from "./subagent-hooks.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createFeishuThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
const baseConfig = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: {} },
|
||||
};
|
||||
|
||||
function registerHandlersForTest(config: Record<string, unknown> = baseConfig) {
|
||||
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
||||
const api = {
|
||||
config,
|
||||
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
||||
handlers.set(hookName, handler);
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
registerFeishuSubagentHooks(api);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
function getRequiredHandler(
|
||||
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
|
||||
hookName: string,
|
||||
): (event: unknown, ctx: unknown) => unknown {
|
||||
const handler = handlers.get(hookName);
|
||||
if (!handler) {
|
||||
throw new Error(`expected ${hookName} hook handler`);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe("feishu subagent hook handlers", () => {
|
||||
beforeEach(() => {
|
||||
threadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("registers Feishu subagent hooks", () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
expect(handlers.has("subagent_spawning")).toBe(true);
|
||||
expect(handlers.has("subagent_delivery_target")).toBe(true);
|
||||
expect(handlers.has("subagent_ended")).toBe(true);
|
||||
expect(handlers.has("subagent_spawned")).toBe(false);
|
||||
});
|
||||
|
||||
it("binds a Feishu DM conversation on subagent_spawning", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
label: "banana",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "ok", threadBindingReady: true });
|
||||
|
||||
const deliveryTargetHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
expect(
|
||||
deliveryTargetHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the original Feishu DM delivery target", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "ou_sender_1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:chat-dm-child",
|
||||
metadata: {
|
||||
deliveryTo: "chat:oc_dm_chat_1",
|
||||
boundBy: "system",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:chat-dm-child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_dm_chat_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_dm_chat_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("binds a Feishu topic conversation and preserves parent context", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
const result = await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:topic-child",
|
||||
agentId: "codex",
|
||||
label: "topic-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "ok", threadBindingReady: true });
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:topic-child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the requester session binding to preserve sender-scoped topic conversations", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "parent",
|
||||
boundBy: "system",
|
||||
},
|
||||
});
|
||||
|
||||
const reboundResult = await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:sender-child",
|
||||
agentId: "codex",
|
||||
label: "sender-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
);
|
||||
|
||||
expect(reboundResult).toEqual({ status: "ok", threadBindingReady: true });
|
||||
expect(manager.listBySessionKey("agent:main:subagent:sender-child")).toMatchObject([
|
||||
{
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:sender-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers requester-matching bindings when multiple child bindings exist", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
agentId: "codex",
|
||||
label: "shared",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
agentId: "codex",
|
||||
label: "shared",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when requester-session bindings remain ambiguous for the same topic", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_2",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:ambiguous-child",
|
||||
agentId: "codex",
|
||||
label: "ambiguous-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:ambiguous-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:mixed-topic-child",
|
||||
agentId: "codex",
|
||||
label: "mixed-topic-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:mixed-topic-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops for non-Feishu channels and non-threaded spawns", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: false,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
endedHandler(
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
reason: "done",
|
||||
accountId: "work",
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an error for unsupported non-topic Feishu group conversations", async () => {
|
||||
const handler = getRequiredHandler(registerHandlersForTest(), "subagent_spawning");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await expect(
|
||||
handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
});
|
||||
|
||||
it("unbinds Feishu bindings on subagent_ended", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
endedHandler(
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
reason: "done",
|
||||
accountId: "work",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:no-manager",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("monitor is not active"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:no-manager",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js";
|
||||
import { normalizeFeishuTarget } from "./targets.js";
|
||||
import { getFeishuThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
function summarizeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function stripProviderPrefix(raw: string): string {
|
||||
return raw.replace(/^(feishu|lark):/i, "").trim();
|
||||
}
|
||||
|
||||
function resolveFeishuRequesterConversation(params: {
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
requesterSessionKey?: string;
|
||||
}): {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null {
|
||||
const manager = getFeishuThreadBindingManager(params.accountId);
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
const rawTo = params.to?.trim();
|
||||
const withoutProviderPrefix = rawTo ? stripProviderPrefix(rawTo) : "";
|
||||
const normalizedTarget = rawTo ? normalizeFeishuTarget(rawTo) : null;
|
||||
const threadId =
|
||||
params.threadId != null && params.threadId !== "" ? String(params.threadId).trim() : "";
|
||||
const isChatTarget = /^(chat|group|channel):/i.test(withoutProviderPrefix);
|
||||
const parsedRequesterTopic =
|
||||
normalizedTarget && threadId && isChatTarget
|
||||
? parseFeishuConversationId({
|
||||
conversationId: buildFeishuConversationId({
|
||||
chatId: normalizedTarget,
|
||||
scope: "group_topic",
|
||||
topicId: threadId,
|
||||
}),
|
||||
parentConversationId: normalizedTarget,
|
||||
})
|
||||
: null;
|
||||
const requesterSessionKey = params.requesterSessionKey?.trim();
|
||||
if (requesterSessionKey) {
|
||||
const existingBindings = manager.listBySessionKey(requesterSessionKey);
|
||||
if (existingBindings.length === 1) {
|
||||
const existing = existingBindings[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
if (existingBindings.length > 1) {
|
||||
if (rawTo && normalizedTarget && !threadId && !isChatTarget) {
|
||||
const directMatches = existingBindings.filter(
|
||||
(entry) =>
|
||||
entry.accountId === manager.accountId &&
|
||||
entry.conversationId === normalizedTarget &&
|
||||
!entry.parentConversationId,
|
||||
);
|
||||
if (directMatches.length === 1) {
|
||||
const existing = directMatches[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (parsedRequesterTopic) {
|
||||
const matchingTopicBindings = existingBindings.filter((entry) => {
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId: entry.conversationId,
|
||||
parentConversationId: entry.parentConversationId,
|
||||
});
|
||||
return (
|
||||
parsed?.chatId === parsedRequesterTopic.chatId &&
|
||||
parsed?.topicId === parsedRequesterTopic.topicId
|
||||
);
|
||||
});
|
||||
if (matchingTopicBindings.length === 1) {
|
||||
const existing = matchingTopicBindings[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
const senderScopedTopicBindings = matchingTopicBindings.filter((entry) => {
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId: entry.conversationId,
|
||||
parentConversationId: entry.parentConversationId,
|
||||
});
|
||||
return parsed?.scope === "group_topic_sender";
|
||||
});
|
||||
if (
|
||||
senderScopedTopicBindings.length === 1 &&
|
||||
matchingTopicBindings.length === senderScopedTopicBindings.length
|
||||
) {
|
||||
const existing = senderScopedTopicBindings[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawTo) {
|
||||
return null;
|
||||
}
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (threadId) {
|
||||
if (!isChatTarget) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accountId: manager.accountId,
|
||||
conversationId: buildFeishuConversationId({
|
||||
chatId: normalizedTarget,
|
||||
scope: "group_topic",
|
||||
topicId: threadId,
|
||||
}),
|
||||
parentConversationId: normalizedTarget,
|
||||
};
|
||||
}
|
||||
|
||||
if (isChatTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: manager.accountId,
|
||||
conversationId: normalizedTarget,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFeishuDeliveryOrigin(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
accountId: string;
|
||||
deliveryTo?: string;
|
||||
deliveryThreadId?: string;
|
||||
}): {
|
||||
channel: "feishu";
|
||||
accountId: string;
|
||||
to: string;
|
||||
threadId?: string;
|
||||
} {
|
||||
const deliveryTo = params.deliveryTo?.trim();
|
||||
const deliveryThreadId = params.deliveryThreadId?.trim();
|
||||
if (deliveryTo) {
|
||||
return {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId,
|
||||
to: deliveryTo,
|
||||
...(deliveryThreadId ? { threadId: deliveryThreadId } : {}),
|
||||
};
|
||||
}
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
if (parsed?.topicId) {
|
||||
return {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId,
|
||||
to: `chat:${params.parentConversationId?.trim() || parsed.chatId}`,
|
||||
threadId: parsed.topicId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId,
|
||||
to: `user:${params.conversationId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatchingChildBinding(params: {
|
||||
accountId?: string;
|
||||
childSessionKey: string;
|
||||
requesterSessionKey?: string;
|
||||
requesterOrigin?: {
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
}) {
|
||||
const manager = getFeishuThreadBindingManager(params.accountId);
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
const childBindings = manager.listBySessionKey(params.childSessionKey.trim());
|
||||
if (childBindings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requesterConversation = resolveFeishuRequesterConversation({
|
||||
accountId: manager.accountId,
|
||||
to: params.requesterOrigin?.to,
|
||||
threadId: params.requesterOrigin?.threadId,
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
});
|
||||
if (requesterConversation) {
|
||||
const matched = childBindings.find(
|
||||
(entry) =>
|
||||
entry.accountId === requesterConversation.accountId &&
|
||||
entry.conversationId === requesterConversation.conversationId &&
|
||||
(entry.parentConversationId?.trim() || undefined) ===
|
||||
(requesterConversation.parentConversationId?.trim() || undefined),
|
||||
);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return childBindings.length === 1 ? childBindings[0] : null;
|
||||
}
|
||||
|
||||
export function registerFeishuSubagentHooks(api: OpenClawPluginApi) {
|
||||
api.on("subagent_spawning", async (event, ctx) => {
|
||||
if (!event.threadRequested) {
|
||||
return;
|
||||
}
|
||||
const requesterChannel = event.requester?.channel?.trim().toLowerCase();
|
||||
if (requesterChannel !== "feishu") {
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = getFeishuThreadBindingManager(event.requester?.accountId);
|
||||
if (!manager) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Feishu current-conversation binding is unavailable because the Feishu account monitor is not active.",
|
||||
};
|
||||
}
|
||||
|
||||
const conversation = resolveFeishuRequesterConversation({
|
||||
accountId: event.requester?.accountId,
|
||||
to: event.requester?.to,
|
||||
threadId: event.requester?.threadId,
|
||||
requesterSessionKey: ctx.requesterSessionKey,
|
||||
});
|
||||
if (!conversation) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Feishu current-conversation binding is only available in direct messages or topic conversations.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const binding = manager.bindConversation({
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: event.childSessionKey,
|
||||
metadata: {
|
||||
agentId: event.agentId,
|
||||
label: event.label,
|
||||
boundBy: "system",
|
||||
deliveryTo: event.requester?.to,
|
||||
deliveryThreadId:
|
||||
event.requester?.threadId != null && event.requester.threadId !== ""
|
||||
? String(event.requester.threadId)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
if (!binding) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Unable to bind this Feishu conversation to the spawned subagent session. Session mode is unavailable for this target.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "ok" as const,
|
||||
threadBindingReady: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error: `Feishu conversation bind failed: ${summarizeError(err)}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
api.on("subagent_delivery_target", (event) => {
|
||||
if (!event.expectsCompletionMessage) {
|
||||
return;
|
||||
}
|
||||
const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
|
||||
if (requesterChannel !== "feishu") {
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = resolveMatchingChildBinding({
|
||||
accountId: event.requesterOrigin?.accountId,
|
||||
childSessionKey: event.childSessionKey,
|
||||
requesterSessionKey: event.requesterSessionKey,
|
||||
requesterOrigin: {
|
||||
to: event.requesterOrigin?.to,
|
||||
threadId: event.requesterOrigin?.threadId,
|
||||
},
|
||||
});
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
origin: resolveFeishuDeliveryOrigin({
|
||||
conversationId: binding.conversationId,
|
||||
parentConversationId: binding.parentConversationId,
|
||||
accountId: binding.accountId,
|
||||
deliveryTo: binding.deliveryTo,
|
||||
deliveryThreadId: binding.deliveryThreadId,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
api.on("subagent_ended", (event) => {
|
||||
const manager = getFeishuThreadBindingManager(event.accountId);
|
||||
manager?.unbindBySessionKey(event.targetSessionKey);
|
||||
});
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ type FeishuThreadBindingRecord = {
|
|||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
deliveryTo?: string;
|
||||
deliveryThreadId?: string;
|
||||
targetKind: FeishuBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId?: string;
|
||||
|
|
@ -108,6 +110,8 @@ function toSessionBindingRecord(
|
|||
agentId: record.agentId,
|
||||
label: record.label,
|
||||
boundBy: record.boundBy,
|
||||
deliveryTo: record.deliveryTo,
|
||||
deliveryThreadId: record.deliveryThreadId,
|
||||
lastActivityAt: record.lastActivityAt,
|
||||
idleTimeoutMs: defaults.idleTimeoutMs,
|
||||
maxAgeMs: defaults.maxAgeMs,
|
||||
|
|
@ -160,6 +164,14 @@ export function createFeishuThreadBindingManager(params: {
|
|||
accountId,
|
||||
conversationId: normalizedConversationId,
|
||||
parentConversationId: parentConversationId?.trim() || undefined,
|
||||
deliveryTo:
|
||||
typeof metadata?.deliveryTo === "string" && metadata.deliveryTo.trim()
|
||||
? metadata.deliveryTo.trim()
|
||||
: undefined,
|
||||
deliveryThreadId:
|
||||
typeof metadata?.deliveryThreadId === "string" && metadata.deliveryThreadId.trim()
|
||||
? metadata.deliveryThreadId.trim()
|
||||
: undefined,
|
||||
targetKind: toFeishuTargetKind(targetKind),
|
||||
targetSessionKey: targetSessionKey.trim(),
|
||||
agentId:
|
||||
|
|
|
|||
|
|
@ -190,6 +190,24 @@ describe("ACP binding cutover schema", () => {
|
|||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects Feishu ACP DM peer IDs keyed by union id", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "on_union_user_123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects bare Feishu group chat ACP peer IDs", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
|
|
|
|||
|
|
@ -90,13 +90,13 @@ const AcpBindingSchema = z
|
|||
}
|
||||
if (
|
||||
channel === "feishu" &&
|
||||
!/^(ou_[^:]+|on_[^:]+|oc_[^:]+:topic:[^:]+(?::sender:[^:]+)?)$/.test(peerId)
|
||||
!/^(ou_[^:]+|oc_[^:]+:topic:[^:]+(?::sender:[^:]+)?)$/.test(peerId)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["match", "peer", "id"],
|
||||
message:
|
||||
"Feishu ACP bindings require canonical DM IDs (ou_xxx/on_xxx) or topic IDs in the form oc_group:topic:om_root[:sender:ou_xxx].",
|
||||
"Feishu ACP bindings require canonical DM IDs (ou_xxx) or topic IDs in the form oc_group:topic:om_root[:sender:ou_xxx].",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue