Merge c40b050f7776fb8b57e81186698c38979cf0b05a into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
kevin song 2026-03-21 11:04:30 +08:00 committed by GitHub
commit 23efcf5f32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 566 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<FeishuToolsConfig> = {
@ -12,6 +12,7 @@ export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
drive: true,
perm: false,
scopes: true,
reaction: true,
};
/**

View File

@ -95,6 +95,7 @@ export type FeishuToolsConfig = {
drive?: boolean;
perm?: boolean;
scopes?: boolean;
reaction?: boolean;
};
export type DynamicAgentCreationConfig = {