feishu: harden media support and align capability docs (#47968)
* feishu: harden media support and action surface * feishu: format media action changes * feishu: fix review follow-ups * fix: scope Feishu target aliases to Feishu (#47968) (thanks @Takhoffman)
This commit is contained in:
parent
476d948732
commit
3c6a49b27e
@ -38,6 +38,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
||||
- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied.
|
||||
- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||
|
||||
@ -711,7 +711,7 @@ Key options:
|
||||
- ✅ Images
|
||||
- ✅ Files
|
||||
- ✅ Audio
|
||||
- ✅ Video
|
||||
- ✅ Video/media
|
||||
- ✅ Stickers
|
||||
|
||||
### Send
|
||||
@ -720,4 +720,28 @@ Key options:
|
||||
- ✅ Images
|
||||
- ✅ Files
|
||||
- ✅ Audio
|
||||
- ⚠️ Rich text (partial support)
|
||||
- ✅ Video/media
|
||||
- ✅ Interactive cards
|
||||
- ⚠️ Rich text (post-style formatting and cards, not arbitrary Feishu authoring features)
|
||||
|
||||
### Threads and replies
|
||||
|
||||
- ✅ Inline replies
|
||||
- ✅ Topic-thread replies where Feishu exposes `reply_in_thread`
|
||||
- ✅ Media replies stay thread-aware when replying to a thread/topic message
|
||||
|
||||
## Runtime action surface
|
||||
|
||||
Feishu currently exposes these runtime actions:
|
||||
|
||||
- `send`
|
||||
- `read`
|
||||
- `edit`
|
||||
- `thread-reply`
|
||||
- `pin`
|
||||
- `list-pins`
|
||||
- `unpin`
|
||||
- `member-info`
|
||||
- `channel-info`
|
||||
- `channel-list`
|
||||
- `react` and `reactions` when reactions are enabled in config
|
||||
|
||||
@ -1163,6 +1163,51 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the message payload filename when download metadata omits it", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockDownloadMessageResourceFeishu.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("video"),
|
||||
contentType: "video/mp4",
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id: "ou-sender",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-media-payload-name",
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "media",
|
||||
content: JSON.stringify({
|
||||
file_key: "file_media_payload",
|
||||
image_key: "img_media_thumb",
|
||||
file_name: "payload-name.mp4",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
"video/mp4",
|
||||
"inbound",
|
||||
expect.any(Number),
|
||||
"payload-name.mp4",
|
||||
);
|
||||
});
|
||||
|
||||
it("downloads embedded media tags from post messages as files", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@ -551,17 +551,17 @@ function parseMediaKeys(
|
||||
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
|
||||
switch (messageType) {
|
||||
case "image":
|
||||
return { imageKey };
|
||||
return { imageKey, fileName: parsed.file_name };
|
||||
case "file":
|
||||
return { fileKey, fileName: parsed.file_name };
|
||||
case "audio":
|
||||
return { fileKey };
|
||||
return { fileKey, fileName: parsed.file_name };
|
||||
case "video":
|
||||
case "media":
|
||||
// Video/media has both file_key (video) and image_key (thumbnail)
|
||||
return { fileKey, imageKey };
|
||||
return { fileKey, imageKey, fileName: parsed.file_name };
|
||||
case "sticker":
|
||||
return { fileKey };
|
||||
return { fileKey, fileName: parsed.file_name };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
export { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } from "./directory.js";
|
||||
export { feishuOutbound } from "./outbound.js";
|
||||
export { createPinFeishu, listPinsFeishu, removePinFeishu } from "./pins.js";
|
||||
export { probeFeishu } from "./probe.js";
|
||||
export { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
|
||||
export { sendCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
export { getChatInfo, getChatMembers, getFeishuMemberInfo } from "./chat.js";
|
||||
export { editMessageFeishu, getMessageFeishu, sendCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
|
||||
@ -1,17 +1,49 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
const addReactionFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const removeReactionFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const getMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const editMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const createPinFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const listPinsFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const removePinFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const getChatInfoMock = vi.hoisted(() => vi.fn());
|
||||
const getChatMembersMock = vi.hoisted(() => vi.fn());
|
||||
const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn());
|
||||
const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn());
|
||||
const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
}));
|
||||
|
||||
vi.mock("./reactions.js", () => ({
|
||||
addReactionFeishu: vi.fn(),
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
addReactionFeishu: addReactionFeishuMock,
|
||||
createPinFeishu: createPinFeishuMock,
|
||||
editMessageFeishu: editMessageFeishuMock,
|
||||
getChatInfo: getChatInfoMock,
|
||||
getChatMembers: getChatMembersMock,
|
||||
getFeishuMemberInfo: getFeishuMemberInfoMock,
|
||||
getMessageFeishu: getMessageFeishuMock,
|
||||
listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock,
|
||||
listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveMock,
|
||||
listPinsFeishu: listPinsFeishuMock,
|
||||
listReactionsFeishu: listReactionsFeishuMock,
|
||||
removeReactionFeishu: vi.fn(),
|
||||
probeFeishu: probeFeishuMock,
|
||||
removePinFeishu: removePinFeishuMock,
|
||||
removeReactionFeishu: removeReactionFeishuMock,
|
||||
sendCardFeishu: sendCardFeishuMock,
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
}));
|
||||
|
||||
import { feishuPlugin } from "./channel.js";
|
||||
@ -68,6 +100,28 @@ describe("feishuPlugin actions", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createFeishuClientMock.mockReturnValue({ tag: "client" });
|
||||
});
|
||||
|
||||
it("advertises the expanded Feishu action surface", () => {
|
||||
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual([
|
||||
"send",
|
||||
"read",
|
||||
"edit",
|
||||
"thread-reply",
|
||||
"pin",
|
||||
"list-pins",
|
||||
"unpin",
|
||||
"member-info",
|
||||
"channel-info",
|
||||
"channel-list",
|
||||
"react",
|
||||
"reactions",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not advertise reactions when disabled via actions config", () => {
|
||||
const disabledCfg = {
|
||||
channels: {
|
||||
@ -82,41 +136,355 @@ describe("feishuPlugin actions", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]);
|
||||
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([
|
||||
"send",
|
||||
"read",
|
||||
"edit",
|
||||
"thread-reply",
|
||||
"pin",
|
||||
"list-pins",
|
||||
"unpin",
|
||||
"member-info",
|
||||
"channel-info",
|
||||
"channel-list",
|
||||
]);
|
||||
});
|
||||
|
||||
it("advertises reactions when any enabled configured account allows them", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
defaultAccount: "main",
|
||||
actions: {
|
||||
reactions: false,
|
||||
},
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_main",
|
||||
appSecret: "secret_main",
|
||||
enabled: true,
|
||||
actions: {
|
||||
reactions: false,
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
appId: "cli_secondary",
|
||||
appSecret: "secret_secondary",
|
||||
enabled: true,
|
||||
actions: {
|
||||
reactions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
it("sends text messages", async () => {
|
||||
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_sent", chatId: "oc_group_1" });
|
||||
|
||||
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", message: "hello" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
text: "hello",
|
||||
accountId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
replyInThread: false,
|
||||
});
|
||||
expect(result?.details).toMatchObject({ ok: true, messageId: "om_sent", chatId: "oc_group_1" });
|
||||
});
|
||||
|
||||
it("sends card messages", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", card: { schema: "2.0" } },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(sendCardFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
card: { schema: "2.0" },
|
||||
accountId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
replyInThread: false,
|
||||
});
|
||||
expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" });
|
||||
});
|
||||
|
||||
it("reads messages", async () => {
|
||||
getMessageFeishuMock.mockResolvedValueOnce({
|
||||
messageId: "om_1",
|
||||
content: "hello",
|
||||
contentType: "text",
|
||||
});
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "read",
|
||||
params: { messageId: "om_1" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never);
|
||||
|
||||
expect(getMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
messageId: "om_1",
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
message: expect.objectContaining({ messageId: "om_1", content: "hello" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an error result when message reads fail", async () => {
|
||||
getMessageFeishuMock.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "read",
|
||||
params: { messageId: "om_missing" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never);
|
||||
|
||||
expect((result as { isError?: boolean } | undefined)?.isError).toBe(true);
|
||||
expect(result?.details).toEqual({
|
||||
error: "Feishu read failed or message not found: om_missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("edits messages", async () => {
|
||||
editMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_2", contentType: "post" });
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "edit",
|
||||
params: { messageId: "om_2", text: "updated" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never);
|
||||
|
||||
expect(editMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
messageId: "om_2",
|
||||
text: "updated",
|
||||
card: undefined,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({ ok: true, messageId: "om_2", contentType: "post" });
|
||||
});
|
||||
|
||||
it("sends explicit thread replies with reply_in_thread semantics", async () => {
|
||||
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_reply", chatId: "oc_group_1" });
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "thread-reply",
|
||||
params: { to: "chat:oc_group_1", messageId: "om_parent", text: "reply body" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
text: "reply body",
|
||||
accountId: undefined,
|
||||
replyToMessageId: "om_parent",
|
||||
replyInThread: true,
|
||||
});
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
action: "thread-reply",
|
||||
messageId: "om_reply",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates pins", async () => {
|
||||
createPinFeishuMock.mockResolvedValueOnce({ messageId: "om_pin", chatId: "oc_group_1" });
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "pin",
|
||||
params: { messageId: "om_pin" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never);
|
||||
|
||||
expect(createPinFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
messageId: "om_pin",
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
pin: expect.objectContaining({ messageId: "om_pin" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists pins", async () => {
|
||||
listPinsFeishuMock.mockResolvedValueOnce({
|
||||
chatId: "oc_group_1",
|
||||
pins: [{ messageId: "om_pin" }],
|
||||
hasMore: false,
|
||||
pageToken: undefined,
|
||||
});
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "list-pins",
|
||||
params: { chatId: "oc_group_1" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(listPinsFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
chatId: "oc_group_1",
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
pageSize: undefined,
|
||||
pageToken: undefined,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
pins: [expect.objectContaining({ messageId: "om_pin" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes pins", async () => {
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "unpin",
|
||||
params: { messageId: "om_pin" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never);
|
||||
|
||||
expect(removePinFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
messageId: "om_pin",
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({ ok: true, messageId: "om_pin" });
|
||||
});
|
||||
|
||||
it("fetches channel info", async () => {
|
||||
getChatInfoMock.mockResolvedValueOnce({ chat_id: "oc_group_1", name: "Eng" });
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "channel-info",
|
||||
params: { chatId: "oc_group_1" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(createFeishuClientMock).toHaveBeenCalled();
|
||||
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1");
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
channel: expect.objectContaining({ chat_id: "oc_group_1", name: "Eng" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches member lists from a chat", async () => {
|
||||
getChatMembersMock.mockResolvedValueOnce({
|
||||
chat_id: "oc_group_1",
|
||||
members: [{ member_id: "ou_1", name: "Alice" }],
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "member-info",
|
||||
params: { chatId: "oc_group_1" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(getChatMembersMock).toHaveBeenCalledWith(
|
||||
{ tag: "client" },
|
||||
"oc_group_1",
|
||||
undefined,
|
||||
undefined,
|
||||
"open_id",
|
||||
);
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
members: [expect.objectContaining({ member_id: "ou_1", name: "Alice" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches individual member info", async () => {
|
||||
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "ou_1", name: "Alice" });
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "member-info",
|
||||
params: { memberId: "ou_1" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_1", "open_id");
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
member: expect.objectContaining({ member_id: "ou_1", name: "Alice" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("infers user_id lookups from the userId alias", async () => {
|
||||
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "member-info",
|
||||
params: { userId: "u_1" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "u_1", "user_id");
|
||||
});
|
||||
|
||||
it("honors explicit open_id over alias heuristics", async () => {
|
||||
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "member-info",
|
||||
params: { userId: "u_1", memberIdType: "open_id" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "u_1", "open_id");
|
||||
});
|
||||
|
||||
it("lists directory-backed peers and groups", async () => {
|
||||
listFeishuDirectoryGroupsLiveMock.mockResolvedValueOnce([{ kind: "group", id: "oc_group_1" }]);
|
||||
listFeishuDirectoryPeersLiveMock.mockResolvedValueOnce([{ kind: "user", id: "ou_1" }]);
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "channel-list",
|
||||
params: { query: "eng", limit: 5 },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never);
|
||||
|
||||
expect(listFeishuDirectoryGroupsLiveMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
query: "eng",
|
||||
limit: 5,
|
||||
fallbackToStatic: false,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(listFeishuDirectoryPeersLiveMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
query: "eng",
|
||||
limit: 5,
|
||||
fallbackToStatic: false,
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({
|
||||
ok: true,
|
||||
groups: [expect.objectContaining({ id: "oc_group_1" })],
|
||||
peers: [expect.objectContaining({ id: "ou_1" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails channel-list when live discovery fails", async () => {
|
||||
listFeishuDirectoryGroupsLiveMock.mockRejectedValueOnce(new Error("token expired"));
|
||||
|
||||
await expect(
|
||||
feishuPlugin.actions?.handleAction?.({
|
||||
action: "channel-list",
|
||||
params: { query: "eng", limit: 5, scope: "groups" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).rejects.toThrow("token expired");
|
||||
});
|
||||
|
||||
it("requires clearAll=true before removing all bot reactions", async () => {
|
||||
@ -132,17 +500,6 @@ describe("feishuPlugin actions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for unsupported Feishu send actions without card payload", async () => {
|
||||
await expect(
|
||||
feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", message: "hello" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).rejects.toThrow('Unsupported Feishu action: "send"');
|
||||
});
|
||||
|
||||
it("allows explicit clearAll=true when removing all bot reactions", async () => {
|
||||
listReactionsFeishuMock.mockResolvedValueOnce([
|
||||
{ reactionId: "r1", operatorType: "app" },
|
||||
@ -161,6 +518,29 @@ describe("feishuPlugin actions", () => {
|
||||
messageId: "om_msg1",
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(removeReactionFeishuMock).toHaveBeenCalledTimes(2);
|
||||
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
|
||||
});
|
||||
|
||||
it("fails for missing params on supported actions", async () => {
|
||||
await expect(
|
||||
feishuPlugin.actions?.handleAction?.({
|
||||
action: "thread-reply",
|
||||
params: { to: "chat:oc_group_1", message: "reply body" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).rejects.toThrow("Feishu thread-reply requires messageId.");
|
||||
});
|
||||
|
||||
it("fails for unsupported action names", async () => {
|
||||
await expect(
|
||||
feishuPlugin.actions?.handleAction?.({
|
||||
action: "search",
|
||||
params: {},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).rejects.toThrow('Unsupported Feishu action: "search"');
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
listEnabledFeishuAccounts,
|
||||
resolveDefaultFeishuAccountId,
|
||||
} from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import { parseFeishuConversationId } from "./conversation-id.js";
|
||||
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
|
||||
@ -167,6 +168,118 @@ function matchFeishuAcpConversation(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function jsonActionResult(details: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function readFirstString(
|
||||
params: Record<string, unknown>,
|
||||
keys: string[],
|
||||
fallback?: string | null,
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = params[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
if (typeof fallback === "string" && fallback.trim()) {
|
||||
return fallback.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readOptionalNumber(params: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = params[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveFeishuActionTarget(ctx: {
|
||||
params: Record<string, unknown>;
|
||||
toolContext?: { currentChannelId?: string } | null;
|
||||
}): string | undefined {
|
||||
return readFirstString(ctx.params, ["to", "target"], ctx.toolContext?.currentChannelId);
|
||||
}
|
||||
|
||||
function resolveFeishuChatId(ctx: {
|
||||
params: Record<string, unknown>;
|
||||
toolContext?: { currentChannelId?: string } | null;
|
||||
}): string | undefined {
|
||||
const raw = readFirstString(
|
||||
ctx.params,
|
||||
["chatId", "chat_id", "channelId", "channel_id", "to", "target"],
|
||||
ctx.toolContext?.currentChannelId,
|
||||
);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^(user|dm|open_id):/i.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^(chat|group|channel):/i.test(raw)) {
|
||||
return normalizeFeishuTarget(raw) ?? undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveFeishuMessageId(params: Record<string, unknown>): string | undefined {
|
||||
return readFirstString(params, ["messageId", "message_id", "replyTo", "reply_to"]);
|
||||
}
|
||||
|
||||
function resolveFeishuMemberId(params: Record<string, unknown>): string | undefined {
|
||||
return readFirstString(params, [
|
||||
"memberId",
|
||||
"member_id",
|
||||
"userId",
|
||||
"user_id",
|
||||
"openId",
|
||||
"open_id",
|
||||
"unionId",
|
||||
"union_id",
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveFeishuMemberIdType(
|
||||
params: Record<string, unknown>,
|
||||
): "open_id" | "user_id" | "union_id" {
|
||||
const raw = readFirstString(params, [
|
||||
"memberIdType",
|
||||
"member_id_type",
|
||||
"userIdType",
|
||||
"user_id_type",
|
||||
]);
|
||||
if (raw === "open_id" || raw === "user_id" || raw === "union_id") {
|
||||
return raw;
|
||||
}
|
||||
if (
|
||||
readFirstString(params, ["userId", "user_id"]) &&
|
||||
!readFirstString(params, ["openId", "open_id", "unionId", "union_id"])
|
||||
) {
|
||||
return "user_id";
|
||||
}
|
||||
if (
|
||||
readFirstString(params, ["unionId", "union_id"]) &&
|
||||
!readFirstString(params, ["openId", "open_id"])
|
||||
) {
|
||||
return "union_id";
|
||||
}
|
||||
return "open_id";
|
||||
}
|
||||
|
||||
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
@ -196,7 +309,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
|
||||
"- Feishu supports interactive cards for rich messages.",
|
||||
"- Feishu supports interactive cards plus native image, file, audio, and video/media delivery.",
|
||||
"- Feishu supports `send`, `read`, `edit`, `thread-reply`, pins, and channel/member lookup, plus reactions when enabled.",
|
||||
],
|
||||
},
|
||||
groups: {
|
||||
@ -284,7 +398,18 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
if (listEnabledFeishuAccounts(cfg).length === 0) {
|
||||
return [];
|
||||
}
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
const actions = new Set<ChannelMessageActionName>([
|
||||
"send",
|
||||
"read",
|
||||
"edit",
|
||||
"thread-reply",
|
||||
"pin",
|
||||
"list-pins",
|
||||
"unpin",
|
||||
"member-info",
|
||||
"channel-info",
|
||||
"channel-list",
|
||||
]);
|
||||
if (areAnyFeishuReactionActionsEnabled(cfg)) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
@ -305,49 +430,303 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
) {
|
||||
throw new Error("Feishu reactions are disabled via actions.reactions.");
|
||||
}
|
||||
if (ctx.action === "send" && ctx.params.card) {
|
||||
const card = ctx.params.card as Record<string, unknown>;
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: "";
|
||||
if (ctx.action === "send" || ctx.action === "thread-reply") {
|
||||
const to = resolveFeishuActionTarget(ctx);
|
||||
if (!to) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: "Feishu card send requires a target (to)." }],
|
||||
details: { error: "Feishu card send requires a target (to)." },
|
||||
};
|
||||
throw new Error(`Feishu ${ctx.action} requires a target (to).`);
|
||||
}
|
||||
const replyToMessageId =
|
||||
typeof ctx.params.replyTo === "string"
|
||||
? ctx.params.replyTo.trim() || undefined
|
||||
ctx.action === "thread-reply" ? resolveFeishuMessageId(ctx.params) : undefined;
|
||||
if (ctx.action === "thread-reply" && !replyToMessageId) {
|
||||
throw new Error("Feishu thread-reply requires messageId.");
|
||||
}
|
||||
const card =
|
||||
ctx.params.card && typeof ctx.params.card === "object"
|
||||
? (ctx.params.card as Record<string, unknown>)
|
||||
: undefined;
|
||||
const { sendCardFeishu } = await loadFeishuChannelRuntime();
|
||||
const result = await sendCardFeishu({
|
||||
const text = readFirstString(ctx.params, ["text", "message"]);
|
||||
if (!card && !text) {
|
||||
throw new Error(`Feishu ${ctx.action} requires text/message or card.`);
|
||||
}
|
||||
const runtime = await loadFeishuChannelRuntime();
|
||||
const result = card
|
||||
? await runtime.sendCardFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
card,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
})
|
||||
: await runtime.sendMessageFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
text: text!,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
});
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: ctx.action,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "read") {
|
||||
const messageId = resolveFeishuMessageId(ctx.params);
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu read requires messageId.");
|
||||
}
|
||||
const { getMessageFeishu } = await loadFeishuChannelRuntime();
|
||||
const message = await getMessageFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
messageId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
if (!message) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
error: `Feishu read failed or message not found: ${messageId}`,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: { error: `Feishu read failed or message not found: ${messageId}` },
|
||||
};
|
||||
}
|
||||
return jsonActionResult({ ok: true, channel: "feishu", action: "read", message });
|
||||
}
|
||||
|
||||
if (ctx.action === "edit") {
|
||||
const messageId = resolveFeishuMessageId(ctx.params);
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu edit requires messageId.");
|
||||
}
|
||||
const text = readFirstString(ctx.params, ["text", "message"]);
|
||||
const card =
|
||||
ctx.params.card && typeof ctx.params.card === "object"
|
||||
? (ctx.params.card as Record<string, unknown>)
|
||||
: undefined;
|
||||
const { editMessageFeishu } = await loadFeishuChannelRuntime();
|
||||
const result = await editMessageFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
text,
|
||||
card,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ ok: true, channel: "feishu", ...result }),
|
||||
},
|
||||
],
|
||||
details: { ok: true, channel: "feishu", ...result },
|
||||
};
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "edit",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "pin") {
|
||||
const messageId = resolveFeishuMessageId(ctx.params);
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu pin requires messageId.");
|
||||
}
|
||||
const { createPinFeishu } = await loadFeishuChannelRuntime();
|
||||
const pin = await createPinFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return jsonActionResult({ ok: true, channel: "feishu", action: "pin", pin });
|
||||
}
|
||||
|
||||
if (ctx.action === "unpin") {
|
||||
const messageId = resolveFeishuMessageId(ctx.params);
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu unpin requires messageId.");
|
||||
}
|
||||
const { removePinFeishu } = await loadFeishuChannelRuntime();
|
||||
await removePinFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "unpin",
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "list-pins") {
|
||||
const chatId = resolveFeishuChatId(ctx);
|
||||
if (!chatId) {
|
||||
throw new Error("Feishu list-pins requires chatId or channelId.");
|
||||
}
|
||||
const { listPinsFeishu } = await loadFeishuChannelRuntime();
|
||||
const result = await listPinsFeishu({
|
||||
cfg: ctx.cfg,
|
||||
chatId,
|
||||
startTime: readFirstString(ctx.params, ["startTime", "start_time"]),
|
||||
endTime: readFirstString(ctx.params, ["endTime", "end_time"]),
|
||||
pageSize: readOptionalNumber(ctx.params, ["pageSize", "page_size"]),
|
||||
pageToken: readFirstString(ctx.params, ["pageToken", "page_token"]),
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "list-pins",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "channel-info") {
|
||||
const chatId = resolveFeishuChatId(ctx);
|
||||
if (!chatId) {
|
||||
throw new Error("Feishu channel-info requires chatId or channelId.");
|
||||
}
|
||||
const runtime = await loadFeishuChannelRuntime();
|
||||
const client = createFeishuClient(account);
|
||||
const channel = await runtime.getChatInfo(client, chatId);
|
||||
const includeMembers = ctx.params.includeMembers === true || ctx.params.members === true;
|
||||
if (!includeMembers) {
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
provider: "feishu",
|
||||
action: "channel-info",
|
||||
channel,
|
||||
});
|
||||
}
|
||||
const members = await runtime.getChatMembers(
|
||||
client,
|
||||
chatId,
|
||||
readOptionalNumber(ctx.params, ["pageSize", "page_size"]),
|
||||
readFirstString(ctx.params, ["pageToken", "page_token"]),
|
||||
resolveFeishuMemberIdType(ctx.params),
|
||||
);
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
provider: "feishu",
|
||||
action: "channel-info",
|
||||
channel,
|
||||
members,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "member-info") {
|
||||
const runtime = await loadFeishuChannelRuntime();
|
||||
const client = createFeishuClient(account);
|
||||
const memberId = resolveFeishuMemberId(ctx.params);
|
||||
if (memberId) {
|
||||
const member = await runtime.getFeishuMemberInfo(
|
||||
client,
|
||||
memberId,
|
||||
resolveFeishuMemberIdType(ctx.params),
|
||||
);
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "member-info",
|
||||
member,
|
||||
});
|
||||
}
|
||||
const chatId = resolveFeishuChatId(ctx);
|
||||
if (!chatId) {
|
||||
throw new Error("Feishu member-info requires memberId or chatId/channelId.");
|
||||
}
|
||||
const members = await runtime.getChatMembers(
|
||||
client,
|
||||
chatId,
|
||||
readOptionalNumber(ctx.params, ["pageSize", "page_size"]),
|
||||
readFirstString(ctx.params, ["pageToken", "page_token"]),
|
||||
resolveFeishuMemberIdType(ctx.params),
|
||||
);
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "member-info",
|
||||
...members,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "channel-list") {
|
||||
const runtime = await loadFeishuChannelRuntime();
|
||||
const query = readFirstString(ctx.params, ["query"]);
|
||||
const limit = readOptionalNumber(ctx.params, ["limit"]);
|
||||
const scope = readFirstString(ctx.params, ["scope", "kind"]) ?? "all";
|
||||
if (
|
||||
scope === "groups" ||
|
||||
scope === "group" ||
|
||||
scope === "channels" ||
|
||||
scope === "channel"
|
||||
) {
|
||||
const groups = await runtime.listFeishuDirectoryGroupsLive({
|
||||
cfg: ctx.cfg,
|
||||
query,
|
||||
limit,
|
||||
fallbackToStatic: false,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "channel-list",
|
||||
groups,
|
||||
});
|
||||
}
|
||||
if (
|
||||
scope === "peers" ||
|
||||
scope === "peer" ||
|
||||
scope === "members" ||
|
||||
scope === "member" ||
|
||||
scope === "users" ||
|
||||
scope === "user"
|
||||
) {
|
||||
const peers = await runtime.listFeishuDirectoryPeersLive({
|
||||
cfg: ctx.cfg,
|
||||
query,
|
||||
limit,
|
||||
fallbackToStatic: false,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "channel-list",
|
||||
peers,
|
||||
});
|
||||
}
|
||||
const [groups, peers] = await Promise.all([
|
||||
runtime.listFeishuDirectoryGroupsLive({
|
||||
cfg: ctx.cfg,
|
||||
query,
|
||||
limit,
|
||||
fallbackToStatic: false,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
}),
|
||||
runtime.listFeishuDirectoryPeersLive({
|
||||
cfg: ctx.cfg,
|
||||
query,
|
||||
limit,
|
||||
fallbackToStatic: false,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
action: "channel-list",
|
||||
groups,
|
||||
peers,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.action === "react") {
|
||||
const messageId =
|
||||
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
|
||||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
|
||||
undefined;
|
||||
const messageId = resolveFeishuMessageId(ctx.params);
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu reaction requires messageId.");
|
||||
}
|
||||
@ -367,12 +746,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
});
|
||||
const ownReaction = matches.find((entry) => entry.operatorType === "app");
|
||||
if (!ownReaction) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: null }) },
|
||||
],
|
||||
details: { ok: true, removed: null },
|
||||
};
|
||||
return jsonActionResult({ ok: true, removed: null });
|
||||
}
|
||||
await removeReactionFeishu({
|
||||
cfg: ctx.cfg,
|
||||
@ -380,12 +754,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
reactionId: ownReaction.reactionId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: emoji }) },
|
||||
],
|
||||
details: { ok: true, removed: emoji },
|
||||
};
|
||||
return jsonActionResult({ ok: true, removed: emoji });
|
||||
}
|
||||
if (!emoji) {
|
||||
if (!clearAll) {
|
||||
@ -409,10 +778,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
});
|
||||
removed += 1;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, removed }) }],
|
||||
details: { ok: true, removed },
|
||||
};
|
||||
return jsonActionResult({ ok: true, removed });
|
||||
}
|
||||
const { addReactionFeishu } = await loadFeishuChannelRuntime();
|
||||
await addReactionFeishu({
|
||||
@ -421,17 +787,11 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
emojiType: emoji,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, added: emoji }) }],
|
||||
details: { ok: true, added: emoji },
|
||||
};
|
||||
return jsonActionResult({ ok: true, added: emoji });
|
||||
}
|
||||
|
||||
if (ctx.action === "reactions") {
|
||||
const messageId =
|
||||
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
|
||||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
|
||||
undefined;
|
||||
const messageId = resolveFeishuMessageId(ctx.params);
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu reactions lookup requires messageId.");
|
||||
}
|
||||
@ -441,10 +801,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
messageId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, reactions }) }],
|
||||
details: { ok: true, reactions },
|
||||
};
|
||||
return jsonActionResult({ ok: true, reactions });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Feishu action: "${String(ctx.action)}"`);
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
const CHAT_ACTION_VALUES = ["members", "info"] as const;
|
||||
const CHAT_ACTION_VALUES = ["members", "info", "member_info"] as const;
|
||||
const MEMBER_ID_TYPE_VALUES = ["open_id", "user_id", "union_id"] as const;
|
||||
|
||||
export const FeishuChatSchema = Type.Object({
|
||||
action: Type.Unsafe<(typeof CHAT_ACTION_VALUES)[number]>({
|
||||
type: "string",
|
||||
enum: [...CHAT_ACTION_VALUES],
|
||||
description: "Action to run: members | info",
|
||||
description: "Action to run: members | info | member_info",
|
||||
}),
|
||||
chat_id: Type.String({ description: "Chat ID (from URL or event payload)" }),
|
||||
chat_id: Type.Optional(Type.String({ description: "Chat ID (from URL or event payload)" })),
|
||||
member_id: Type.Optional(Type.String({ description: "Member ID for member_info lookups" })),
|
||||
page_size: Type.Optional(Type.Number({ description: "Page size (1-100, default 50)" })),
|
||||
page_token: Type.Optional(Type.String({ description: "Pagination token" })),
|
||||
member_id_type: Type.Optional(
|
||||
|
||||
@ -2,15 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerFeishuChatTools } from "./chat.js";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
const chatGetMock = vi.hoisted(() => vi.fn());
|
||||
const chatMembersGetMock = vi.hoisted(() => vi.fn());
|
||||
const contactUserGetMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
describe("registerFeishuChatTools", () => {
|
||||
const chatGetMock = vi.hoisted(() => vi.fn());
|
||||
const chatMembersGetMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createFeishuClientMock.mockReturnValue({
|
||||
@ -18,6 +18,9 @@ describe("registerFeishuChatTools", () => {
|
||||
chat: { get: chatGetMock },
|
||||
chatMembers: { get: chatMembersGetMock },
|
||||
},
|
||||
contact: {
|
||||
user: { get: contactUserGetMock },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -66,6 +69,31 @@ describe("registerFeishuChatTools", () => {
|
||||
members: [expect.objectContaining({ member_id: "ou_1", name: "member1" })],
|
||||
}),
|
||||
);
|
||||
|
||||
contactUserGetMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
user: {
|
||||
open_id: "ou_1",
|
||||
name: "member1",
|
||||
email: "member1@example.com",
|
||||
department_ids: ["od_1"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const memberInfoResult = await tool.execute("tc_3", {
|
||||
action: "member_info",
|
||||
member_id: "ou_1",
|
||||
});
|
||||
expect(memberInfoResult.details).toEqual(
|
||||
expect.objectContaining({
|
||||
member_id: "ou_1",
|
||||
open_id: "ou_1",
|
||||
name: "member1",
|
||||
email: "member1@example.com",
|
||||
department_ids: ["od_1"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips registration when chat tool is disabled", () => {
|
||||
|
||||
@ -12,7 +12,7 @@ function json(data: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
async function getChatInfo(client: Lark.Client, chatId: string) {
|
||||
export async function getChatInfo(client: Lark.Client, chatId: string) {
|
||||
const res = await client.im.chat.get({ path: { chat_id: chatId } });
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
@ -36,7 +36,7 @@ async function getChatInfo(client: Lark.Client, chatId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function getChatMembers(
|
||||
export async function getChatMembers(
|
||||
client: Lark.Client,
|
||||
chatId: string,
|
||||
pageSize?: number,
|
||||
@ -71,6 +71,55 @@ async function getChatMembers(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFeishuMemberInfo(
|
||||
client: Lark.Client,
|
||||
memberId: string,
|
||||
memberIdType: "open_id" | "user_id" | "union_id" = "open_id",
|
||||
) {
|
||||
const res = await client.contact.user.get({
|
||||
path: { user_id: memberId },
|
||||
params: {
|
||||
user_id_type: memberIdType,
|
||||
department_id_type: "open_department_id",
|
||||
},
|
||||
});
|
||||
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const user = res.data?.user;
|
||||
return {
|
||||
member_id: memberId,
|
||||
member_id_type: memberIdType,
|
||||
open_id: user?.open_id,
|
||||
user_id: user?.user_id,
|
||||
union_id: user?.union_id,
|
||||
name: user?.name,
|
||||
en_name: user?.en_name,
|
||||
nickname: user?.nickname,
|
||||
email: user?.email,
|
||||
enterprise_email: user?.enterprise_email,
|
||||
mobile: user?.mobile,
|
||||
mobile_visible: user?.mobile_visible,
|
||||
status: user?.status,
|
||||
avatar: user?.avatar,
|
||||
department_ids: user?.department_ids,
|
||||
department_path: user?.department_path,
|
||||
leader_user_id: user?.leader_user_id,
|
||||
city: user?.city,
|
||||
country: user?.country,
|
||||
work_station: user?.work_station,
|
||||
join_time: user?.join_time,
|
||||
is_tenant_manager: user?.is_tenant_manager,
|
||||
employee_no: user?.employee_no,
|
||||
employee_type: user?.employee_type,
|
||||
description: user?.description,
|
||||
job_title: user?.job_title,
|
||||
geo: user?.geo,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_chat: No config available, skipping chat tools");
|
||||
@ -96,7 +145,7 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
||||
{
|
||||
name: "feishu_chat",
|
||||
label: "Feishu Chat",
|
||||
description: "Feishu chat operations. Actions: members, info",
|
||||
description: "Feishu chat operations. Actions: members, info, member_info",
|
||||
parameters: FeishuChatSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as FeishuChatParams;
|
||||
@ -104,6 +153,9 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
||||
const client = getClient();
|
||||
switch (p.action) {
|
||||
case "members":
|
||||
if (!p.chat_id) {
|
||||
return json({ error: "chat_id is required for action members" });
|
||||
}
|
||||
return json(
|
||||
await getChatMembers(
|
||||
client,
|
||||
@ -114,7 +166,17 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
||||
),
|
||||
);
|
||||
case "info":
|
||||
if (!p.chat_id) {
|
||||
return json({ error: "chat_id is required for action info" });
|
||||
}
|
||||
return json(await getChatInfo(client, p.chat_id));
|
||||
case "member_info":
|
||||
if (!p.member_id) {
|
||||
return json({ error: "member_id is required for action member_info" });
|
||||
}
|
||||
return json(
|
||||
await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"),
|
||||
);
|
||||
default:
|
||||
return json({ error: `Unknown action: ${String(p.action)}` });
|
||||
}
|
||||
|
||||
@ -1,27 +1,45 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveFeishuAccount: vi.fn(() => ({
|
||||
configured: false,
|
||||
config: {
|
||||
allowFrom: ["user:alice", "user:bob"],
|
||||
dms: {
|
||||
"user:carla": {},
|
||||
},
|
||||
groups: {
|
||||
"chat-1": {},
|
||||
},
|
||||
groupAllowFrom: ["chat-2"],
|
||||
},
|
||||
})),
|
||||
resolveFeishuAccount: resolveFeishuAccountMock,
|
||||
}));
|
||||
|
||||
import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.js";
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
listFeishuDirectoryGroups,
|
||||
listFeishuDirectoryGroupsLive,
|
||||
listFeishuDirectoryPeers,
|
||||
listFeishuDirectoryPeersLive,
|
||||
} from "./directory.js";
|
||||
|
||||
describe("feishu directory (config-backed)", () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
function makeStaticAccount() {
|
||||
return {
|
||||
configured: false,
|
||||
config: {
|
||||
allowFrom: ["user:alice", "user:bob"],
|
||||
dms: {
|
||||
"user:carla": {},
|
||||
},
|
||||
groups: {
|
||||
"chat-1": {},
|
||||
},
|
||||
groupAllowFrom: ["chat-2"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
resolveFeishuAccountMock.mockImplementation(() => makeStaticAccount());
|
||||
|
||||
it("merges allowFrom + dms into peer entries", async () => {
|
||||
const peers = await listFeishuDirectoryPeers({ cfg, query: "a" });
|
||||
expect(peers).toEqual([
|
||||
@ -37,4 +55,64 @@ describe("feishu directory (config-backed)", () => {
|
||||
{ kind: "group", id: "chat-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to static peers on live lookup failure by default", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
...makeStaticAccount(),
|
||||
configured: true,
|
||||
});
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
contact: {
|
||||
user: {
|
||||
list: vi.fn(async () => {
|
||||
throw new Error("token expired");
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const peers = await listFeishuDirectoryPeersLive({ cfg, query: "a" });
|
||||
expect(peers).toEqual([
|
||||
{ kind: "user", id: "alice" },
|
||||
{ kind: "user", id: "carla" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces live peer lookup failures when fallback is disabled", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
...makeStaticAccount(),
|
||||
configured: true,
|
||||
});
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
contact: {
|
||||
user: {
|
||||
list: vi.fn(async () => {
|
||||
throw new Error("token expired");
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(listFeishuDirectoryPeersLive({ cfg, fallbackToStatic: false })).rejects.toThrow(
|
||||
"token expired",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces live group lookup failures when fallback is disabled", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
...makeStaticAccount(),
|
||||
configured: true,
|
||||
});
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
list: vi.fn(async () => ({ code: 999, msg: "forbidden" })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(listFeishuDirectoryGroupsLive({ cfg, fallbackToStatic: false })).rejects.toThrow(
|
||||
"forbidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@ export async function listFeishuDirectoryPeersLive(params: {
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
fallbackToStatic?: boolean;
|
||||
}): Promise<FeishuDirectoryPeer[]> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
@ -32,27 +33,32 @@ export async function listFeishuDirectoryPeersLive(params: {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data?.items) {
|
||||
for (const user of response.data.items) {
|
||||
if (user.open_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = user.name || "";
|
||||
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
peers.push({
|
||||
kind: "user",
|
||||
id: user.open_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (peers.length >= limit) {
|
||||
break;
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || `code ${response.code}`);
|
||||
}
|
||||
|
||||
for (const user of response.data?.items ?? []) {
|
||||
if (user.open_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = user.name || "";
|
||||
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
peers.push({
|
||||
kind: "user",
|
||||
id: user.open_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (peers.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return peers;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (params.fallbackToStatic === false) {
|
||||
throw err instanceof Error ? err : new Error("Feishu live peer lookup failed");
|
||||
}
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
}
|
||||
@ -62,6 +68,7 @@ export async function listFeishuDirectoryGroupsLive(params: {
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
fallbackToStatic?: boolean;
|
||||
}): Promise<FeishuDirectoryGroup[]> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
@ -79,27 +86,32 @@ export async function listFeishuDirectoryGroupsLive(params: {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data?.items) {
|
||||
for (const chat of response.data.items) {
|
||||
if (chat.chat_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = chat.name || "";
|
||||
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
groups.push({
|
||||
kind: "group",
|
||||
id: chat.chat_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (groups.length >= limit) {
|
||||
break;
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.msg || `code ${response.code}`);
|
||||
}
|
||||
|
||||
for (const chat of response.data?.items ?? []) {
|
||||
if (chat.chat_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = chat.name || "";
|
||||
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
groups.push({
|
||||
kind: "group",
|
||||
id: chat.chat_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (groups.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (params.fallbackToStatic === false) {
|
||||
throw err instanceof Error ? err : new Error("Feishu live group lookup failed");
|
||||
}
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +195,58 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses msg_type=media for remote mp4 content even when the filename is generic", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("remote-video"),
|
||||
fileName: "download",
|
||||
kind: "video",
|
||||
contentType: "video/mp4",
|
||||
});
|
||||
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaUrl: "https://example.com/video",
|
||||
});
|
||||
|
||||
expect(fileCreateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ file_type: "mp4" }),
|
||||
}),
|
||||
);
|
||||
expect(messageCreateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ msg_type: "media" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to generic file for unsupported audio formats", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("remote-mp3"),
|
||||
fileName: "song.mp3",
|
||||
kind: "audio",
|
||||
contentType: "audio/mpeg",
|
||||
});
|
||||
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaUrl: "https://example.com/song.mp3",
|
||||
});
|
||||
|
||||
expect(fileCreateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ file_type: "stream" }),
|
||||
}),
|
||||
);
|
||||
expect(messageCreateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ msg_type: "file" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("configures the media client timeout for image uploads", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
@ -520,4 +572,27 @@ describe("downloadMessageResourceFeishu", () => {
|
||||
expectMediaTimeoutClientConfigured();
|
||||
expect(result.buffer).toBeInstanceOf(Buffer);
|
||||
});
|
||||
|
||||
it("extracts content-type and filename metadata from download headers", async () => {
|
||||
messageResourceGetMock.mockResolvedValueOnce({
|
||||
data: Buffer.from("fake-video-data"),
|
||||
headers: {
|
||||
"content-type": "video/mp4",
|
||||
"content-disposition": `attachment; filename="clip.mp4"`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg: {} as any,
|
||||
messageId: "om_video_msg",
|
||||
fileKey: "file_key_video",
|
||||
type: "file",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
buffer: Buffer.from("fake-video-data"),
|
||||
contentType: "video/mp4",
|
||||
fileName: "clip.mp4",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { Readable } from "stream";
|
||||
import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { mediaKindFromMime } from "../../../src/media/constants.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
@ -61,6 +62,75 @@ function extractFeishuUploadKey(
|
||||
return key;
|
||||
}
|
||||
|
||||
function readHeaderValue(
|
||||
headers: Record<string, unknown> | undefined,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const target = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() !== target) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((entry) => typeof entry === "string" && entry.trim());
|
||||
if (typeof first === "string") {
|
||||
return first.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function decodeDispositionFileName(value: string): string | undefined {
|
||||
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, "$1"));
|
||||
} catch {
|
||||
return utf8Match[1].trim().replace(/^"(.*)"$/, "$1");
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = value.match(/filename="?([^";]+)"?/i);
|
||||
return plainMatch?.[1]?.trim();
|
||||
}
|
||||
|
||||
function extractFeishuDownloadMetadata(response: unknown): {
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
} {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
const headers =
|
||||
(responseAny.headers as Record<string, unknown> | undefined) ??
|
||||
(responseAny.header as Record<string, unknown> | undefined);
|
||||
|
||||
const contentType =
|
||||
readHeaderValue(headers, "content-type") ??
|
||||
(typeof responseAny.contentType === "string" ? responseAny.contentType : undefined) ??
|
||||
(typeof responseAny.mime_type === "string" ? responseAny.mime_type : undefined) ??
|
||||
(typeof responseAny.data?.contentType === "string"
|
||||
? responseAny.data.contentType
|
||||
: undefined) ??
|
||||
(typeof responseAny.data?.mime_type === "string" ? responseAny.data.mime_type : undefined);
|
||||
|
||||
const disposition = readHeaderValue(headers, "content-disposition");
|
||||
const fileName =
|
||||
(disposition ? decodeDispositionFileName(disposition) : undefined) ??
|
||||
(typeof responseAny.file_name === "string" ? responseAny.file_name : undefined) ??
|
||||
(typeof responseAny.fileName === "string" ? responseAny.fileName : undefined) ??
|
||||
(typeof responseAny.data?.file_name === "string" ? responseAny.data.file_name : undefined) ??
|
||||
(typeof responseAny.data?.fileName === "string" ? responseAny.data.fileName : undefined);
|
||||
|
||||
return { contentType, fileName };
|
||||
}
|
||||
|
||||
async function readFeishuResponseBuffer(params: {
|
||||
response: unknown;
|
||||
tmpDirPrefix: string;
|
||||
@ -144,7 +214,8 @@ export async function downloadImageFeishu(params: {
|
||||
tmpDirPrefix: "openclaw-feishu-img-",
|
||||
errorPrefix: "Feishu image download failed",
|
||||
});
|
||||
return { buffer };
|
||||
const meta = extractFeishuDownloadMetadata(response);
|
||||
return { buffer, contentType: meta.contentType };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,7 +246,7 @@ export async function downloadMessageResourceFeishu(params: {
|
||||
tmpDirPrefix: "openclaw-feishu-resource-",
|
||||
errorPrefix: "Feishu message resource download failed",
|
||||
});
|
||||
return { buffer };
|
||||
return { buffer, ...extractFeishuDownloadMetadata(response) };
|
||||
}
|
||||
|
||||
export type UploadImageResult = {
|
||||
@ -401,6 +472,53 @@ export function detectFileType(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?: string }): {
|
||||
fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
|
||||
msgType: "image" | "file" | "audio" | "media";
|
||||
} {
|
||||
const { fileName, contentType } = params;
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
const mimeKind = mediaKindFromMime(contentType);
|
||||
|
||||
const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
|
||||
ext,
|
||||
);
|
||||
if (isImageExt || mimeKind === "image") {
|
||||
return { msgType: "image" };
|
||||
}
|
||||
|
||||
if (
|
||||
ext === ".opus" ||
|
||||
ext === ".ogg" ||
|
||||
contentType === "audio/ogg" ||
|
||||
contentType === "audio/opus"
|
||||
) {
|
||||
return { fileType: "opus", msgType: "audio" };
|
||||
}
|
||||
|
||||
if (
|
||||
[".mp4", ".mov", ".avi"].includes(ext) ||
|
||||
contentType === "video/mp4" ||
|
||||
contentType === "video/quicktime" ||
|
||||
contentType === "video/x-msvideo"
|
||||
) {
|
||||
return { fileType: "mp4", msgType: "media" };
|
||||
}
|
||||
|
||||
const fileType = detectFileType(fileName);
|
||||
return {
|
||||
fileType,
|
||||
msgType:
|
||||
fileType === "stream"
|
||||
? "file"
|
||||
: fileType === "opus"
|
||||
? "audio"
|
||||
: fileType === "mp4"
|
||||
? "media"
|
||||
: "file",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload and send media (image or file) from URL, local path, or buffer.
|
||||
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
|
||||
@ -437,6 +555,7 @@ export async function sendMediaFeishu(params: {
|
||||
|
||||
let buffer: Buffer;
|
||||
let name: string;
|
||||
let contentType: string | undefined;
|
||||
|
||||
if (mediaBuffer) {
|
||||
buffer = mediaBuffer;
|
||||
@ -449,33 +568,29 @@ export async function sendMediaFeishu(params: {
|
||||
});
|
||||
buffer = loaded.buffer;
|
||||
name = fileName ?? loaded.fileName ?? "file";
|
||||
contentType = loaded.contentType;
|
||||
} else {
|
||||
throw new Error("Either mediaUrl or mediaBuffer must be provided");
|
||||
}
|
||||
|
||||
// Determine if it's an image based on extension
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
|
||||
const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType });
|
||||
|
||||
if (isImage) {
|
||||
if (routing.msgType === "image") {
|
||||
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
|
||||
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
|
||||
} else {
|
||||
const fileType = detectFileType(name);
|
||||
const { fileKey } = await uploadFileFeishu({
|
||||
cfg,
|
||||
file: buffer,
|
||||
fileName: name,
|
||||
fileType,
|
||||
fileType: routing.fileType ?? "stream",
|
||||
accountId,
|
||||
});
|
||||
// Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file"
|
||||
const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
|
||||
return sendFileFeishu({
|
||||
cfg,
|
||||
to,
|
||||
fileKey,
|
||||
msgType,
|
||||
msgType: routing.msgType,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
|
||||
108
extensions/feishu/src/pins.ts
Normal file
108
extensions/feishu/src/pins.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
|
||||
export type FeishuPin = {
|
||||
messageId: string;
|
||||
chatId?: string;
|
||||
operatorId?: string;
|
||||
operatorIdType?: string;
|
||||
createTime?: string;
|
||||
};
|
||||
|
||||
function assertFeishuPinApiSuccess(response: { code?: number; msg?: string }, action: string) {
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePin(pin: {
|
||||
message_id: string;
|
||||
chat_id?: string;
|
||||
operator_id?: string;
|
||||
operator_id_type?: string;
|
||||
create_time?: string;
|
||||
}): FeishuPin {
|
||||
return {
|
||||
messageId: pin.message_id,
|
||||
chatId: pin.chat_id,
|
||||
operatorId: pin.operator_id,
|
||||
operatorIdType: pin.operator_id_type,
|
||||
createTime: pin.create_time,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPinFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuPin | null> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const response = await client.im.pin.create({
|
||||
data: {
|
||||
message_id: params.messageId,
|
||||
},
|
||||
});
|
||||
assertFeishuPinApiSuccess(response, "pin create");
|
||||
return response.data?.pin ? normalizePin(response.data.pin) : null;
|
||||
}
|
||||
|
||||
export async function removePinFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const response = await client.im.pin.delete({
|
||||
path: {
|
||||
message_id: params.messageId,
|
||||
},
|
||||
});
|
||||
assertFeishuPinApiSuccess(response, "pin delete");
|
||||
}
|
||||
|
||||
export async function listPinsFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
chatId: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
pageSize?: number;
|
||||
pageToken?: string;
|
||||
accountId?: string;
|
||||
}): Promise<{ chatId: string; pins: FeishuPin[]; hasMore: boolean; pageToken?: string }> {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const response = await client.im.pin.list({
|
||||
params: {
|
||||
chat_id: params.chatId,
|
||||
...(params.startTime ? { start_time: params.startTime } : {}),
|
||||
...(params.endTime ? { end_time: params.endTime } : {}),
|
||||
...(typeof params.pageSize === "number"
|
||||
? { page_size: Math.max(1, Math.min(100, Math.floor(params.pageSize))) }
|
||||
: {}),
|
||||
...(params.pageToken ? { page_token: params.pageToken } : {}),
|
||||
},
|
||||
});
|
||||
assertFeishuPinApiSuccess(response, "pin list");
|
||||
|
||||
return {
|
||||
chatId: params.chatId,
|
||||
pins: (response.data?.items ?? []).map(normalizePin),
|
||||
hasMore: response.data?.has_more === true,
|
||||
pageToken: response.data?.page_token,
|
||||
};
|
||||
}
|
||||
@ -171,6 +171,75 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails thread replies instead of falling back to a top-level send", async () => {
|
||||
replyMock.mockResolvedValue({
|
||||
code: 230011,
|
||||
msg: "The message was withdrawn.",
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "chat:oc_group_1",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
replyInThread: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.",
|
||||
);
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(replyMock).toHaveBeenCalledWith({
|
||||
path: { message_id: "om_parent" },
|
||||
data: expect.objectContaining({
|
||||
reply_in_thread: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("fails thrown withdrawn thread replies instead of falling back to create", async () => {
|
||||
const sdkError = Object.assign(new Error("request failed"), { code: 230011 });
|
||||
replyMock.mockRejectedValue(sdkError);
|
||||
|
||||
await expect(
|
||||
sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "chat:oc_group_1",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
replyInThread: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.",
|
||||
);
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still falls back for non-thread replies to withdrawn targets", async () => {
|
||||
replyMock.mockResolvedValue({
|
||||
code: 230011,
|
||||
msg: "The message was withdrawn.",
|
||||
});
|
||||
createMock.mockResolvedValue({
|
||||
code: 0,
|
||||
data: { message_id: "om_non_thread_fallback" },
|
||||
});
|
||||
|
||||
await expectFallbackResult(
|
||||
() =>
|
||||
sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
replyInThread: false,
|
||||
}),
|
||||
"om_non_thread_fallback",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-throws non-withdrawn thrown errors for card messages", async () => {
|
||||
const sdkError = Object.assign(new Error("permission denied"), { code: 99991401 });
|
||||
replyMock.mockRejectedValue(sdkError);
|
||||
|
||||
@ -2,18 +2,25 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildStructuredCard,
|
||||
editMessageFeishu,
|
||||
getMessageFeishu,
|
||||
listFeishuThreadMessages,
|
||||
resolveFeishuCardTemplate,
|
||||
} from "./send.js";
|
||||
|
||||
const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
|
||||
vi.hoisted(() => ({
|
||||
mockClientGet: vi.fn(),
|
||||
mockClientList: vi.fn(),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveFeishuAccount: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockClientGet,
|
||||
mockClientList,
|
||||
mockClientPatch,
|
||||
mockCreateFeishuClient,
|
||||
mockResolveFeishuAccount,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClientGet: vi.fn(),
|
||||
mockClientList: vi.fn(),
|
||||
mockClientPatch: vi.fn(),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveFeishuAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
@ -23,6 +30,17 @@ vi.mock("./accounts.js", () => ({
|
||||
resolveFeishuAccount: mockResolveFeishuAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getFeishuRuntime: () => ({
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: () => "preserve",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("getMessageFeishu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -35,6 +53,7 @@ describe("getMessageFeishu", () => {
|
||||
message: {
|
||||
get: mockClientGet,
|
||||
list: mockClientList,
|
||||
patch: mockClientPatch,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -239,6 +258,70 @@ describe("getMessageFeishu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMessageFeishu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveFeishuAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
});
|
||||
mockCreateFeishuClient.mockReturnValue({
|
||||
im: {
|
||||
message: {
|
||||
patch: mockClientPatch,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("patches post content for text edits", async () => {
|
||||
mockClientPatch.mockResolvedValueOnce({ code: 0 });
|
||||
|
||||
const result = await editMessageFeishu({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
messageId: "om_edit",
|
||||
text: "updated body",
|
||||
});
|
||||
|
||||
expect(mockClientPatch).toHaveBeenCalledWith({
|
||||
path: { message_id: "om_edit" },
|
||||
data: {
|
||||
content: JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[
|
||||
{
|
||||
tag: "md",
|
||||
text: "updated body",
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ messageId: "om_edit", contentType: "post" });
|
||||
});
|
||||
|
||||
it("patches interactive content for card edits", async () => {
|
||||
mockClientPatch.mockResolvedValueOnce({ code: 0 });
|
||||
|
||||
const result = await editMessageFeishu({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
messageId: "om_card",
|
||||
card: { schema: "2.0" },
|
||||
});
|
||||
|
||||
expect(mockClientPatch).toHaveBeenCalledWith({
|
||||
path: { message_id: "om_card" },
|
||||
data: {
|
||||
content: JSON.stringify({ schema: "2.0" }),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ messageId: "om_card", contentType: "interactive" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuCardTemplate", () => {
|
||||
it("accepts supported Feishu templates", () => {
|
||||
expect(resolveFeishuCardTemplate(" purple ")).toBe("purple");
|
||||
|
||||
@ -139,6 +139,12 @@ async function sendReplyOrFallbackDirect(
|
||||
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
|
||||
}
|
||||
|
||||
const threadReplyFallbackError = params.replyInThread
|
||||
? new Error(
|
||||
"Feishu thread reply failed: reply target is unavailable and cannot safely fall back to a top-level send.",
|
||||
)
|
||||
: null;
|
||||
|
||||
let response: { code?: number; msg?: string; data?: { message_id?: string } };
|
||||
try {
|
||||
response = await client.im.message.reply({
|
||||
@ -153,9 +159,15 @@ async function sendReplyOrFallbackDirect(
|
||||
if (!isWithdrawnReplyError(err)) {
|
||||
throw err;
|
||||
}
|
||||
if (threadReplyFallbackError) {
|
||||
throw threadReplyFallbackError;
|
||||
}
|
||||
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
|
||||
}
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
if (threadReplyFallbackError) {
|
||||
throw threadReplyFallbackError;
|
||||
}
|
||||
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
|
||||
}
|
||||
assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
|
||||
@ -406,7 +418,7 @@ export type SendFeishuMessageParams = {
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
function buildFeishuPostMessagePayload(params: { messageText: string }): {
|
||||
export function buildFeishuPostMessagePayload(params: { messageText: string }): {
|
||||
content: string;
|
||||
msgType: string;
|
||||
} {
|
||||
@ -486,6 +498,59 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMessageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
text?: string;
|
||||
card?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}): Promise<{ messageId: string; contentType: "post" | "interactive" }> {
|
||||
const { cfg, messageId, text, card, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const hasText = typeof text === "string" && text.trim().length > 0;
|
||||
const hasCard = Boolean(card);
|
||||
if (hasText === hasCard) {
|
||||
throw new Error("Feishu edit requires exactly one of text or card.");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
if (card) {
|
||||
const content = JSON.stringify(card);
|
||||
const response = await client.im.message.patch({
|
||||
path: { message_id: messageId },
|
||||
data: { content },
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return { messageId, contentType: "interactive" };
|
||||
}
|
||||
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text!, tableMode);
|
||||
const payload = buildFeishuPostMessagePayload({ messageText });
|
||||
const response = await client.im.message.patch({
|
||||
path: { message_id: messageId },
|
||||
data: { content: payload.content },
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return { messageId, contentType: "post" };
|
||||
}
|
||||
|
||||
export async function updateCardFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
@ -627,41 +692,3 @@ export async function sendMarkdownCardFeishu(params: {
|
||||
const card = buildMarkdownCard(cardText);
|
||||
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing text message.
|
||||
* Note: Feishu only allows editing messages within 24 hours.
|
||||
*/
|
||||
export async function editMessageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
text: string;
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, messageId, text, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
|
||||
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
|
||||
|
||||
const response = await client.im.message.update({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
msg_type: msgType,
|
||||
content,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -319,6 +319,8 @@ function buildReactionSchema() {
|
||||
function buildFetchSchema() {
|
||||
return {
|
||||
limit: Type.Optional(Type.Number()),
|
||||
pageSize: Type.Optional(Type.Number()),
|
||||
pageToken: Type.Optional(Type.String()),
|
||||
before: Type.Optional(Type.String()),
|
||||
after: Type.Optional(Type.String()),
|
||||
around: Type.Optional(Type.String()),
|
||||
@ -386,16 +388,27 @@ function buildChannelTargetSchema() {
|
||||
channelId: Type.Optional(
|
||||
Type.String({ description: "Channel id filter (search/thread list/event create)." }),
|
||||
),
|
||||
chatId: Type.Optional(
|
||||
Type.String({ description: "Chat id for chat-scoped metadata actions." }),
|
||||
),
|
||||
channelIds: Type.Optional(
|
||||
Type.Array(Type.String({ description: "Channel id filter (repeatable)." })),
|
||||
),
|
||||
memberId: Type.Optional(Type.String()),
|
||||
memberIdType: Type.Optional(Type.String()),
|
||||
guildId: Type.Optional(Type.String()),
|
||||
userId: Type.Optional(Type.String()),
|
||||
openId: Type.Optional(Type.String()),
|
||||
unionId: Type.Optional(Type.String()),
|
||||
authorId: Type.Optional(Type.String()),
|
||||
authorIds: Type.Optional(Type.Array(Type.String())),
|
||||
roleId: Type.Optional(Type.String()),
|
||||
roleIds: Type.Optional(Type.Array(Type.String())),
|
||||
participant: Type.Optional(Type.String()),
|
||||
includeMembers: Type.Optional(Type.Boolean()),
|
||||
members: Type.Optional(Type.Boolean()),
|
||||
scope: Type.Optional(Type.String()),
|
||||
kind: Type.Optional(Type.String()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -115,6 +115,47 @@ describe("normalizeMessageActionInput", () => {
|
||||
expect("to" in normalized).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Feishu message and chat aliases without forcing canonical targets", () => {
|
||||
const pin = normalizeMessageActionInput({
|
||||
action: "pin",
|
||||
args: {
|
||||
channel: "feishu",
|
||||
messageId: "om_123",
|
||||
},
|
||||
});
|
||||
const listPins = normalizeMessageActionInput({
|
||||
action: "list-pins",
|
||||
args: {
|
||||
channel: "feishu",
|
||||
chatId: "oc_123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(pin.messageId).toBe("om_123");
|
||||
expect("target" in pin).toBe(false);
|
||||
expect("to" in pin).toBe(false);
|
||||
expect(listPins.chatId).toBe("oc_123");
|
||||
expect("target" in listPins).toBe(false);
|
||||
expect("to" in listPins).toBe(false);
|
||||
});
|
||||
|
||||
it("still backfills target for non-Feishu read actions with messageId-only input", () => {
|
||||
const normalized = normalizeMessageActionInput({
|
||||
action: "read",
|
||||
args: {
|
||||
channel: "slack",
|
||||
messageId: "123.456",
|
||||
},
|
||||
toolContext: {
|
||||
currentChannelId: "C12345678",
|
||||
currentChannelProvider: "slack",
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.target).toBe("C12345678");
|
||||
expect(normalized.messageId).toBe("123.456");
|
||||
});
|
||||
|
||||
it("maps legacy channelId inputs through canonical target for channel-id actions", () => {
|
||||
const normalized = normalizeMessageActionInput({
|
||||
action: "channel-info",
|
||||
|
||||
@ -16,6 +16,10 @@ export function normalizeMessageActionInput(params: {
|
||||
}): Record<string, unknown> {
|
||||
const normalizedArgs = { ...params.args };
|
||||
const { action, toolContext } = params;
|
||||
const explicitChannel =
|
||||
typeof normalizedArgs.channel === "string" ? normalizedArgs.channel.trim() : "";
|
||||
const inferredChannel =
|
||||
explicitChannel || normalizeMessageChannel(toolContext?.currentChannelProvider) || "";
|
||||
|
||||
const explicitTarget =
|
||||
typeof normalizedArgs.target === "string" ? normalizedArgs.target.trim() : "";
|
||||
@ -34,7 +38,7 @@ export function normalizeMessageActionInput(params: {
|
||||
!explicitTarget &&
|
||||
!hasLegacyTarget &&
|
||||
actionRequiresTarget(action) &&
|
||||
!actionHasTarget(action, normalizedArgs)
|
||||
!actionHasTarget(action, normalizedArgs, { channel: inferredChannel })
|
||||
) {
|
||||
const inferredTarget = toolContext?.currentChannelId?.trim();
|
||||
if (inferredTarget) {
|
||||
@ -54,17 +58,17 @@ export function normalizeMessageActionInput(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const explicitChannel =
|
||||
typeof normalizedArgs.channel === "string" ? normalizedArgs.channel.trim() : "";
|
||||
if (!explicitChannel) {
|
||||
const inferredChannel = normalizeMessageChannel(toolContext?.currentChannelProvider);
|
||||
if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) {
|
||||
normalizedArgs.channel = inferredChannel;
|
||||
}
|
||||
}
|
||||
|
||||
applyTargetToParams({ action, args: normalizedArgs });
|
||||
if (actionRequiresTarget(action) && !actionHasTarget(action, normalizedArgs)) {
|
||||
if (
|
||||
actionRequiresTarget(action) &&
|
||||
!actionHasTarget(action, normalizedArgs, { channel: inferredChannel })
|
||||
) {
|
||||
throw new Error(`Action ${action} requires a target.`);
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,105 @@ function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = {
|
||||
}
|
||||
|
||||
describe("runMessageAction plugin dispatch", () => {
|
||||
describe("alias-based plugin action dispatch", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
ok: true,
|
||||
params,
|
||||
}),
|
||||
);
|
||||
|
||||
const feishuLikePlugin: ChannelPlugin = {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
selectionLabel: "Feishu",
|
||||
docsPath: "/channels/feishu",
|
||||
blurb: "Feishu action dispatch test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "channel"] },
|
||||
config: createAlwaysConfiguredPluginConfig(),
|
||||
actions: {
|
||||
listActions: () => ["pin", "list-pins", "member-info"],
|
||||
supportsAction: ({ action }) =>
|
||||
action === "pin" || action === "list-pins" || action === "member-info",
|
||||
handleAction,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "feishu",
|
||||
source: "test",
|
||||
plugin: feishuLikePlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
handleAction.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("dispatches messageId/chatId-based Feishu actions through the shared runner", async () => {
|
||||
await runMessageAction({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
action: "pin",
|
||||
params: {
|
||||
channel: "feishu",
|
||||
messageId: "om_123",
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
await runMessageAction({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
action: "list-pins",
|
||||
params: {
|
||||
channel: "feishu",
|
||||
chatId: "oc_123",
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(handleAction).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
action: "pin",
|
||||
params: expect.objectContaining({
|
||||
messageId: "om_123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(handleAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
action: "list-pins",
|
||||
params: expect.objectContaining({
|
||||
chatId: "oc_123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media caption behavior", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
|
||||
@ -20,12 +20,25 @@ describe("actionHasTarget", () => {
|
||||
});
|
||||
|
||||
it("detects alias targets for message and chat actions", () => {
|
||||
expect(actionHasTarget("read", { messageId: "msg_123" }, { channel: "feishu" })).toBe(true);
|
||||
expect(actionHasTarget("edit", { messageId: " msg_123 " })).toBe(true);
|
||||
expect(actionHasTarget("pin", { messageId: "msg_123" }, { channel: "feishu" })).toBe(true);
|
||||
expect(actionHasTarget("unpin", { messageId: "msg_123" }, { channel: "feishu" })).toBe(true);
|
||||
expect(actionHasTarget("list-pins", { chatId: "oc_123" }, { channel: "feishu" })).toBe(true);
|
||||
expect(actionHasTarget("channel-info", { chatId: "oc_123" }, { channel: "feishu" })).toBe(true);
|
||||
expect(actionHasTarget("react", { chatGuid: "chat-guid" })).toBe(true);
|
||||
expect(actionHasTarget("react", { chatIdentifier: "chat-id" })).toBe(true);
|
||||
expect(actionHasTarget("react", { chatId: 42 })).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes Feishu-only aliases to Feishu", () => {
|
||||
expect(actionHasTarget("read", { messageId: "msg_123" })).toBe(false);
|
||||
expect(actionHasTarget("pin", { messageId: "msg_123" }, { channel: "slack" })).toBe(false);
|
||||
expect(actionHasTarget("channel-info", { chatId: "oc_123" }, { channel: "discord" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects blank and non-finite alias targets", () => {
|
||||
expect(actionHasTarget("edit", { messageId: " " })).toBe(false);
|
||||
expect(actionHasTarget("react", { chatGuid: "" })).toBe(false);
|
||||
|
||||
@ -60,15 +60,25 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
"download-file": "none",
|
||||
};
|
||||
|
||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||
unsend: ["messageId"],
|
||||
edit: ["messageId"],
|
||||
react: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
leaveGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
type ActionTargetAliasSpec = {
|
||||
aliases: string[];
|
||||
channels?: string[];
|
||||
};
|
||||
|
||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, ActionTargetAliasSpec>> = {
|
||||
read: { aliases: ["messageId"], channels: ["feishu"] },
|
||||
unsend: { aliases: ["messageId"] },
|
||||
edit: { aliases: ["messageId"] },
|
||||
pin: { aliases: ["messageId"], channels: ["feishu"] },
|
||||
unpin: { aliases: ["messageId"], channels: ["feishu"] },
|
||||
"list-pins": { aliases: ["chatId"], channels: ["feishu"] },
|
||||
"channel-info": { aliases: ["chatId"], channels: ["feishu"] },
|
||||
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
removeParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
|
||||
};
|
||||
|
||||
export function actionRequiresTarget(action: ChannelMessageActionName): boolean {
|
||||
@ -78,6 +88,7 @@ export function actionRequiresTarget(action: ChannelMessageActionName): boolean
|
||||
export function actionHasTarget(
|
||||
action: ChannelMessageActionName,
|
||||
params: Record<string, unknown>,
|
||||
options?: { channel?: string },
|
||||
): boolean {
|
||||
const to = typeof params.to === "string" ? params.to.trim() : "";
|
||||
if (to) {
|
||||
@ -87,11 +98,17 @@ export function actionHasTarget(
|
||||
if (channelId) {
|
||||
return true;
|
||||
}
|
||||
const aliases = ACTION_TARGET_ALIASES[action];
|
||||
if (!aliases) {
|
||||
const spec = ACTION_TARGET_ALIASES[action];
|
||||
if (!spec) {
|
||||
return false;
|
||||
}
|
||||
return aliases.some((alias) => {
|
||||
if (
|
||||
spec.channels &&
|
||||
(!options?.channel || !spec.channels.includes(options.channel.trim().toLowerCase()))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return spec.aliases.some((alias) => {
|
||||
const value = params[alias];
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user