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:
Tak Hoffman 2026-03-16 02:02:48 -05:00 committed by GitHub
parent 476d948732
commit 3c6a49b27e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1900 additions and 245 deletions

View File

@ -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.

View File

@ -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

View File

@ -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);

View File

@ -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 {};
}

View File

@ -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";

View File

@ -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"');
});
});

View File

@ -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)}"`);

View File

@ -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(

View File

@ -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", () => {

View File

@ -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)}` });
}

View File

@ -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",
);
});
});

View File

@ -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);
}
}

View File

@ -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",
});
});
});

View File

@ -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,

View 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,
};
}

View File

@ -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);

View File

@ -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");

View File

@ -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}`}`);
}
}

View File

@ -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()),
};
}

View File

@ -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",

View File

@ -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.`);
}

View File

@ -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([]));

View File

@ -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);

View File

@ -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;