From 3c6a49b27ea0c77f50b0183dea4aef4679feb911 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:02:48 -0500 Subject: [PATCH] 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) --- CHANGELOG.md | 2 + docs/channels/feishu.md | 28 +- extensions/feishu/src/bot.test.ts | 45 ++ extensions/feishu/src/bot.ts | 8 +- extensions/feishu/src/channel.runtime.ts | 4 +- extensions/feishu/src/channel.test.ts | 474 +++++++++++++++-- extensions/feishu/src/channel.ts | 481 +++++++++++++++--- extensions/feishu/src/chat-schema.ts | 7 +- extensions/feishu/src/chat.test.ts | 34 +- extensions/feishu/src/chat.ts | 68 ++- extensions/feishu/src/directory.test.ts | 106 +++- extensions/feishu/src/directory.ts | 76 +-- extensions/feishu/src/media.test.ts | 75 +++ extensions/feishu/src/media.ts | 137 ++++- extensions/feishu/src/pins.ts | 108 ++++ .../feishu/src/send.reply-fallback.test.ts | 69 +++ extensions/feishu/src/send.test.ts | 97 +++- extensions/feishu/src/send.ts | 105 ++-- src/agents/tools/message-tool.ts | 13 + .../message-action-normalization.test.ts | 41 ++ .../outbound/message-action-normalization.ts | 14 +- ...sage-action-runner.plugin-dispatch.test.ts | 99 ++++ .../outbound/message-action-spec.test.ts | 13 + src/infra/outbound/message-action-spec.ts | 41 +- 24 files changed, 1900 insertions(+), 245 deletions(-) create mode 100644 extensions/feishu/src/pins.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 384fcffc330..a80bae4ced0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 3768906d940..41882e78264 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -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 diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 3e14bcdadd5..df787b0106a 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -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); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index fc84801b124..728bb9a8ffc 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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 {}; } diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts index 61f637a94de..0e4d9fc7583 100644 --- a/extensions/feishu/src/channel.runtime.ts +++ b/extensions/feishu/src/channel.runtime.ts @@ -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"; diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index e7db645be0b..826ca1c26fb 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -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"'); + }); }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 450b1fbe88f..5d47c55e16b 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -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) { + return { + content: [{ type: "text" as const, text: JSON.stringify(details) }], + details, + }; +} + +function readFirstString( + params: Record, + 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, 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; + toolContext?: { currentChannelId?: string } | null; +}): string | undefined { + return readFirstString(ctx.params, ["to", "target"], ctx.toolContext?.currentChannelId); +} + +function resolveFeishuChatId(ctx: { + params: Record; + 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 | undefined { + return readFirstString(params, ["messageId", "message_id", "replyTo", "reply_to"]); +} + +function resolveFeishuMemberId(params: Record): string | undefined { + return readFirstString(params, [ + "memberId", + "member_id", + "userId", + "user_id", + "openId", + "open_id", + "unionId", + "union_id", + ]); +} + +function resolveFeishuMemberIdType( + params: Record, +): "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 = { id: "feishu", meta: { @@ -196,7 +309,8 @@ export const feishuPlugin: ChannelPlugin = { 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 = { if (listEnabledFeishuAccounts(cfg).length === 0) { return []; } - const actions = new Set(); + const actions = new Set([ + "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 = { ) { throw new Error("Feishu reactions are disabled via actions.reactions."); } - if (ctx.action === "send" && ctx.params.card) { - const card = ctx.params.card as Record; - 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) : 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) + : 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 = { }); 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 = { 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 = { }); 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 = { 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 = { 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)}"`); diff --git a/extensions/feishu/src/chat-schema.ts b/extensions/feishu/src/chat-schema.ts index 5f7bdd6a5c7..5460f11dcc9 100644 --- a/extensions/feishu/src/chat-schema.ts +++ b/extensions/feishu/src/chat-schema.ts @@ -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( diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts index 9ebf579f962..d06442b12f8 100644 --- a/extensions/feishu/src/chat.test.ts +++ b/extensions/feishu/src/chat.test.ts @@ -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", () => { diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts index df168d579ee..9c62e5648b2 100644 --- a/extensions/feishu/src/chat.ts +++ b/extensions/feishu/src/chat.ts @@ -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)}` }); } diff --git a/extensions/feishu/src/directory.test.ts b/extensions/feishu/src/directory.test.ts index c06b2fb6c80..805f2f006e9 100644 --- a/extensions/feishu/src/directory.test.ts +++ b/extensions/feishu/src/directory.test.ts @@ -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", + ); + }); }); diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts index c6366990204..af6ed8859cf 100644 --- a/extensions/feishu/src/directory.ts +++ b/extensions/feishu/src/directory.ts @@ -15,6 +15,7 @@ export async function listFeishuDirectoryPeersLive(params: { query?: string; limit?: number; accountId?: string; + fallbackToStatic?: boolean; }): Promise { 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 { 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); } } diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 80555c294ae..67ea2c1b77f 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -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", + }); + }); }); diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 45596fe45ed..b7888b7069e 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -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 | 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 | undefined) ?? + (responseAny.header as Record | 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, diff --git a/extensions/feishu/src/pins.ts b/extensions/feishu/src/pins.ts new file mode 100644 index 00000000000..0205acf3aa3 --- /dev/null +++ b/extensions/feishu/src/pins.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts index 610ded167fd..d4a2f023ac1 100644 --- a/extensions/feishu/src/send.reply-fallback.test.ts +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -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); diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts index 21ef7e53a1a..ecad7a6332e 100644 --- a/extensions/feishu/src/send.test.ts +++ b/extensions/feishu/src/send.test.ts @@ -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"); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 57c0fbc0600..09015ee593b 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -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; + 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 { - 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}`}`); - } -} diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index e0711ecf8ae..0e6c846e75d 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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()), }; } diff --git a/src/infra/outbound/message-action-normalization.test.ts b/src/infra/outbound/message-action-normalization.test.ts index 87fa7a8503c..2ee5f35b3dd 100644 --- a/src/infra/outbound/message-action-normalization.test.ts +++ b/src/infra/outbound/message-action-normalization.test.ts @@ -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", diff --git a/src/infra/outbound/message-action-normalization.ts b/src/infra/outbound/message-action-normalization.ts index a4b4f4829bd..ff40ca45e6a 100644 --- a/src/infra/outbound/message-action-normalization.ts +++ b/src/infra/outbound/message-action-normalization.ts @@ -16,6 +16,10 @@ export function normalizeMessageActionInput(params: { }): Record { 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.`); } diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 00c4bafef11..952bf16f51c 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -15,6 +15,105 @@ function createAlwaysConfiguredPluginConfig(account: Record = { } describe("runMessageAction plugin dispatch", () => { + describe("alias-based plugin action dispatch", () => { + const handleAction = vi.fn(async ({ params }: { params: Record }) => + 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([])); diff --git a/src/infra/outbound/message-action-spec.test.ts b/src/infra/outbound/message-action-spec.test.ts index 138f61e08a0..cc8a127799f 100644 --- a/src/infra/outbound/message-action-spec.test.ts +++ b/src/infra/outbound/message-action-spec.test.ts @@ -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); diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index f4f715d869d..a71bc35b6fb 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -60,15 +60,25 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { - 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> = { + 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, + 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;