diff --git a/docs/extensions/feishu-reactions.md b/docs/extensions/feishu-reactions.md new file mode 100644 index 00000000000..b7ce8fe97e6 --- /dev/null +++ b/docs/extensions/feishu-reactions.md @@ -0,0 +1,136 @@ +# Feishu Reaction Tool + +The Feishu reaction tool allows agents to add, remove, and list emoji reactions on messages. + +## Setup + +Enable the reaction tool in your Feishu config: + +```json +{ + "channels": { + "feishu": { + "tools": { + "reaction": true + } + } + } +} +``` + +## Usage + +### Add a reaction + +```json +{ + "action": "add", + "message_id": "msg_xxxxxx", + "emoji_type": "THUMBSUP" +} +``` + +### Remove a reaction + +```json +{ + "action": "remove", + "message_id": "msg_xxxxxx", + "reaction_id": "react_xxxxxx" +} +``` + +### List reactions + +```json +{ + "action": "list", + "message_id": "msg_xxxxxx" +} +``` + +### List reactions with specific emoji + +```json +{ + "action": "list", + "message_id": "msg_xxxxxx", + "emoji_type": "HEART" +} +``` + +## Available Emoji Types + +Common emoji types: + +- `THUMBSUP` 👍 +- `THUMBSDOWN` 👎 +- `HEART` ❤️ +- `SMILE` 😊 +- `GRINNING` 😀 +- `LAUGHING` 😂 +- `CRY` 😢 +- `ANGRY` 😠 +- `SURPRISED` 😲 +- `THINKING` 🤔 +- `CLAP` 👏 +- `OK` 👌 +- `FIST` ✊ +- `PRAY` 🙏 +- `FIRE` 🔥 +- `PARTY` 🎉 +- `CHECK` ✅ +- `CROSS` ❌ +- `QUESTION` ❓ +- `EXCLAMATION` ❗ + +For a complete list, see the [Feishu emoji documentation](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce). + +## Examples + +### React to a message with thumbs up + +```typescript +await feishu_reaction({ + action: "add", + message_id: "msg_123", + emoji_type: "THUMBSUP" +}); +``` + +### Check all reactions on a message + +```typescript +const result = await feishu_reaction({ + action: "list", + message_id: "msg_123" +}); +// Returns: { reactions: [{ reactionId, emojiType, operatorType, operatorId }] } +``` + +### Remove a specific reaction + +```typescript +await feishu_reaction({ + action: "remove", + message_id: "msg_123", + reaction_id: "react_456" +}); +``` + +## Error Handling + +The tool returns errors in the following cases: + +- Missing required `emoji_type` for add action +- Missing required `reaction_id` for remove action +- Invalid action type +- API failures (network, auth, etc.) + +Example error response: + +```json +{ + "error": "emoji_type is required for add action" +} +``` diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index 1e18c0eea12..f64bba13eb7 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -5,6 +5,7 @@ import { registerFeishuChatTools } from "./src/chat.js"; import { registerFeishuDocTools } from "./src/docx.js"; import { registerFeishuDriveTools } from "./src/drive.js"; import { registerFeishuPermTools } from "./src/perm.js"; +import { registerFeishuReactionTools } from "./src/reaction.js"; import { setFeishuRuntime } from "./src/runtime.js"; import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js"; import { registerFeishuWikiTools } from "./src/wiki.js"; @@ -60,5 +61,6 @@ export default defineChannelPluginEntry({ registerFeishuDriveTools(api); registerFeishuPermTools(api); registerFeishuBitableTools(api); + registerFeishuReactionTools(api); }, }); diff --git a/extensions/feishu/src/reaction-schema.test.ts b/extensions/feishu/src/reaction-schema.test.ts new file mode 100644 index 00000000000..60c56c6511e --- /dev/null +++ b/extensions/feishu/src/reaction-schema.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { FeishuReactionSchema } from "./reaction-schema.js"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; + +const ReactionValidator = TypeCompiler.Compile(FeishuReactionSchema); + +describe("FeishuReactionSchema", () => { + describe("action field", () => { + it("accepts valid action: add", () => { + const result = ReactionValidator.Check({ + action: "add", + message_id: "msg_123", + emoji_type: "THUMBSUP", + }); + expect(result).toBe(true); + }); + + it("accepts valid action: remove", () => { + const result = ReactionValidator.Check({ + action: "remove", + message_id: "msg_123", + reaction_id: "react_456", + }); + expect(result).toBe(true); + }); + + it("accepts valid action: list", () => { + const result = ReactionValidator.Check({ + action: "list", + message_id: "msg_123", + }); + expect(result).toBe(true); + }); + + it("rejects invalid action", () => { + const result = ReactionValidator.Check({ + action: "invalid", + message_id: "msg_123", + }); + expect(result).toBe(false); + }); + }); + + describe("message_id field", () => { + it("accepts valid message_id", () => { + const result = ReactionValidator.Check({ + action: "list", + message_id: "msg_123", + }); + expect(result).toBe(true); + }); + + it("rejects missing message_id", () => { + const result = ReactionValidator.Check({ + action: "list", + }); + expect(result).toBe(false); + }); + + it("rejects empty message_id", () => { + const result = ReactionValidator.Check({ + action: "list", + message_id: "", + }); + expect(result).toBe(false); + }); + }); + + describe("emoji_type field", () => { + it("accepts common emoji types", () => { + const types = ["THUMBSUP", "HEART", "SMILE", "FIRE", "CLAP", "OK", "PRAY"]; + for (const type of types) { + const result = ReactionValidator.Check({ + action: "add", + message_id: "msg_123", + emoji_type: type, + }); + expect(result).toBe(true); + } + }); + + it("accepts any string emoji type", () => { + const result = ReactionValidator.Check({ + action: "add", + message_id: "msg_123", + emoji_type: "CUSTOM_EMOJI", + }); + expect(result).toBe(true); + }); + + it("allows optional emoji_type for list action", () => { + const result = ReactionValidator.Check({ + action: "list", + message_id: "msg_123", + }); + expect(result).toBe(true); + }); + }); + + describe("reaction_id field", () => { + it("accepts valid reaction_id", () => { + const result = ReactionValidator.Check({ + action: "remove", + message_id: "msg_123", + reaction_id: "react_456", + }); + expect(result).toBe(true); + }); + + it("allows optional reaction_id for add/list actions", () => { + const result = ReactionValidator.Check({ + action: "add", + message_id: "msg_123", + emoji_type: "THUMBSUP", + }); + expect(result).toBe(true); + }); + }); + + describe("account_id field", () => { + it("accepts optional account_id", () => { + const result = ReactionValidator.Check({ + action: "list", + message_id: "msg_123", + account_id: "acc_123", + }); + expect(result).toBe(true); + }); + }); + + describe("validation errors", () => { + it("provides error details for invalid data", () => { + const errors = Array.from(ReactionValidator.Errors({ + action: "invalid", + message_id: "msg_123", + })); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects wrong type for action", () => { + const result = ReactionValidator.Check({ + action: 123, + message_id: "msg_123", + }); + expect(result).toBe(false); + }); + + it("rejects wrong type for message_id", () => { + const result = ReactionValidator.Check({ + action: "list", + message_id: 123, + }); + expect(result).toBe(false); + }); + }); +}); diff --git a/extensions/feishu/src/reaction-schema.ts b/extensions/feishu/src/reaction-schema.ts new file mode 100644 index 00000000000..15b7a873e72 --- /dev/null +++ b/extensions/feishu/src/reaction-schema.ts @@ -0,0 +1,26 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const REACTION_ACTION_VALUES = ["add", "remove", "list"] as const; + +export const FeishuReactionSchema = Type.Object({ + action: Type.Unsafe<(typeof REACTION_ACTION_VALUES)[number]>({ + type: "string", + enum: [...REACTION_ACTION_VALUES], + description: "Action to run: add | remove | list", + }), + message_id: Type.String({ description: "Message ID to react to" }), + emoji_type: Type.Optional( + Type.String({ + description: + 'Feishu emoji type (required for add/remove). Examples: THUMBSUP, HEART, SMILE, FIRE, CLAP, OK, PRAY', + }), + ), + reaction_id: Type.Optional( + Type.String({ + description: "Reaction ID (required for remove action, returned by add/list)", + }), + ), + account_id: Type.Optional(Type.String({ description: "Feishu account ID (optional)" })), +}); + +export type FeishuReactionParams = Static; diff --git a/extensions/feishu/src/reaction.test.ts b/extensions/feishu/src/reaction.test.ts new file mode 100644 index 00000000000..dd63bed84c0 --- /dev/null +++ b/extensions/feishu/src/reaction.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { registerFeishuReactionTools } from "./reaction.js"; +import * as reactions from "./reactions.js"; + +describe("feishu_reaction tool", () => { + let mockApi: Partial; + let registeredTool: any; + + beforeEach(() => { + registeredTool = null; + mockApi = { + config: { + channels: { + feishu: { + appId: "test_app_id", + appSecret: "test_app_secret", + encryptKey: "test_encrypt_key", + verificationToken: "test_token", + }, + }, + } as any, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + registerTool: vi.fn((tool) => { + registeredTool = tool; + }), + }; + }); + + it("registers feishu_reaction tool when reaction is enabled", () => { + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + expect(mockApi.registerTool).toHaveBeenCalled(); + expect(registeredTool?.name).toBe("feishu_reaction"); + }); + + it("execute add action calls addReactionFeishu", async () => { + vi.spyOn(reactions, "addReactionFeishu").mockResolvedValue({ reactionId: "react_123" }); + + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + + const result = await registeredTool.execute("call_1", { + action: "add", + message_id: "msg_123", + emoji_type: "THUMBSUP", + }); + + expect(reactions.addReactionFeishu).toHaveBeenCalledWith({ + cfg: mockApi.config, + messageId: "msg_123", + emojiType: "THUMBSUP", + accountId: undefined, + }); + expect(result.content[0].text).toContain("reaction_123"); + }); + + it("add action returns error when emoji_type is missing", async () => { + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + + const result = await registeredTool.execute("call_1", { + action: "add", + message_id: "msg_123", + }); + + expect(result.content[0].text).toContain("emoji_type is required"); + }); + + it("execute remove action calls removeReactionFeishu", async () => { + vi.spyOn(reactions, "removeReactionFeishu").mockResolvedValue(); + + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + + const result = await registeredTool.execute("call_1", { + action: "remove", + message_id: "msg_123", + reaction_id: "react_123", + }); + + expect(reactions.removeReactionFeishu).toHaveBeenCalledWith({ + cfg: mockApi.config, + messageId: "msg_123", + reactionId: "react_123", + accountId: undefined, + }); + expect(result.content[0].text).toContain("success"); + }); + + it("remove action returns error when reaction_id is missing", async () => { + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + + const result = await registeredTool.execute("call_1", { + action: "remove", + message_id: "msg_123", + }); + + expect(result.content[0].text).toContain("reaction_id is required"); + }); + + it("execute list action calls listReactionsFeishu", async () => { + vi.spyOn(reactions, "listReactionsFeishu").mockResolvedValue([ + { reactionId: "r1", emojiType: "THUMBSUP", operatorType: "user", operatorId: "u1" }, + ]); + + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + + const result = await registeredTool.execute("call_1", { + action: "list", + message_id: "msg_123", + }); + + expect(reactions.listReactionsFeishu).toHaveBeenCalledWith({ + cfg: mockApi.config, + messageId: "msg_123", + emojiType: undefined, + accountId: undefined, + }); + expect(result.content[0].text).toContain("THUMBSUP"); + }); + + it("returns error for unknown action", async () => { + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + + const result = await registeredTool.execute("call_1", { + action: "unknown" as any, + message_id: "msg_123", + }); + + expect(result.content[0].text).toContain("Unknown action"); + }); + + it("handles errors gracefully", async () => { + vi.spyOn(reactions, "addReactionFeishu").mockRejectedValue(new Error("API failed")); + + registerFeishuReactionTools(mockApi as OpenClawPluginApi); + + const result = await registeredTool.execute("call_1", { + action: "add", + message_id: "msg_123", + emoji_type: "THUMBSUP", + }); + + expect(result.content[0].text).toContain("API failed"); + }); +}); diff --git a/extensions/feishu/src/reaction.ts b/extensions/feishu/src/reaction.ts new file mode 100644 index 00000000000..75f04e330d8 --- /dev/null +++ b/extensions/feishu/src/reaction.ts @@ -0,0 +1,95 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { listEnabledFeishuAccounts } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { FeishuReactionSchema, type FeishuReactionParams } from "./reaction-schema.js"; +import { + addReactionFeishu, + removeReactionFeishu, + listReactionsFeishu, + FeishuEmoji, +} from "./reactions.js"; +import { resolveToolsConfig } from "./tools-config.js"; + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +export function registerFeishuReactionTools(api: OpenClawPluginApi) { + if (!api.config) { + api.logger.debug?.("feishu_reaction: No config available, skipping reaction tools"); + return; + } + + const accounts = listEnabledFeishuAccounts(api.config); + if (accounts.length === 0) { + api.logger.debug?.("feishu_reaction: No Feishu accounts configured, skipping reaction tools"); + return; + } + + const firstAccount = accounts[0]; + const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + if (!toolsCfg.reaction) { + api.logger.debug?.("feishu_reaction: reaction tool disabled in config"); + return; + } + + api.registerTool( + { + name: "feishu_reaction", + label: "Feishu Reaction", + description: + "Feishu message reaction operations. Actions: add, remove, list. Common emojis: THUMBSUP, HEART, SMILE, FIRE, CLAP, OK, PRAY", + parameters: FeishuReactionSchema, + async execute(_toolCallId, params) { + const p = params as FeishuReactionParams; + try { + switch (p.action) { + case "add": { + if (!p.emoji_type) { + return json({ error: "emoji_type is required for add action" }); + } + const result = await addReactionFeishu({ + cfg: api.config, + messageId: p.message_id, + emojiType: p.emoji_type, + accountId: p.account_id, + }); + return json({ success: true, ...result }); + } + case "remove": { + if (!p.reaction_id) { + return json({ error: "reaction_id is required for remove action" }); + } + await removeReactionFeishu({ + cfg: api.config, + messageId: p.message_id, + reactionId: p.reaction_id, + accountId: p.account_id, + }); + return json({ success: true, action: "removed" }); + } + case "list": { + const reactions = await listReactionsFeishu({ + cfg: api.config, + messageId: p.message_id, + emojiType: p.emoji_type, + accountId: p.account_id, + }); + return json({ reactions }); + } + default: + return json({ error: `Unknown action: ${String(p.action)}` }); + } + } catch (err) { + return json({ error: err instanceof Error ? err.message : String(err) }); + } + }, + }, + { name: "feishu_reaction" }, + ); + + api.logger.info?.("feishu_reaction: Registered feishu_reaction tool"); +} diff --git a/extensions/feishu/src/tools-config.ts b/extensions/feishu/src/tools-config.ts index 1890f626583..6071bd57f76 100644 --- a/extensions/feishu/src/tools-config.ts +++ b/extensions/feishu/src/tools-config.ts @@ -2,7 +2,7 @@ import type { FeishuToolsConfig } from "./types.js"; /** * Default tool configuration. - * - doc, chat, wiki, drive, scopes: enabled by default + * - doc, chat, wiki, drive, scopes, reaction: enabled by default * - perm: disabled by default (sensitive operation) */ export const DEFAULT_TOOLS_CONFIG: Required = { @@ -12,6 +12,7 @@ export const DEFAULT_TOOLS_CONFIG: Required = { drive: true, perm: false, scopes: true, + reaction: true, }; /** diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index 4f365c3ae00..3e153359f8a 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -95,6 +95,7 @@ export type FeishuToolsConfig = { drive?: boolean; perm?: boolean; scopes?: boolean; + reaction?: boolean; }; export type DynamicAgentCreationConfig = {