@m1heng-clawd/feishu 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { parseBuffer } from "music-metadata";
2
+
1
3
  /**
2
4
  * Media duration parsers for Feishu file upload.
3
5
  *
@@ -9,11 +11,38 @@
9
11
  * - MP4 (ISO base media) — MP4, MOV, QuickTime, M4A, M4V, 3GP share this format
10
12
  * - WAV (RIFF PCM) — uncompressed audio
11
13
  *
12
- * Formats intentionally skipped (complex frame-level parsing, rare in practice):
13
- * - MP3 (VBR makes byte-count estimation unreliable without scanning all frames)
14
- * - Raw AAC / ADTS
14
+ * Raw AAC / ADTS is delegated to music-metadata on the audio path.
15
15
  */
16
16
 
17
+ async function parseAudioDurationWithMusicMetadata(params: {
18
+ buffer: Buffer;
19
+ fileName?: string;
20
+ contentType?: string;
21
+ }): Promise<number | undefined> {
22
+ const { buffer, fileName, contentType } = params;
23
+ try {
24
+ const metadata = await parseBuffer(
25
+ buffer,
26
+ {
27
+ mimeType: contentType,
28
+ path: fileName,
29
+ size: buffer.length,
30
+ },
31
+ {
32
+ duration: true,
33
+ skipCovers: true,
34
+ },
35
+ );
36
+ const seconds = metadata.format.duration;
37
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
38
+ return undefined;
39
+ }
40
+ return Math.round(seconds * 1000);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
17
46
  /**
18
47
  * Parse duration from an OGG container (Opus or Vorbis).
19
48
  * Reads the granule position from the last OGG page and divides by sample rate.
@@ -174,17 +203,23 @@ export function parseWavDurationMs(buffer: Buffer): number | undefined {
174
203
  *
175
204
  * Routes to the appropriate parser based on Feishu's file type:
176
205
  * - "mp4" → MP4/MOV/QuickTime container parser
177
- * - "opus" → OGG parser, then MP4 container (covers M4A), then WAV
206
+ * - "opus" → music-metadata first, then OGG parser, then MP4 container, then WAV
178
207
  *
179
208
  * Returns duration in milliseconds, or undefined if not determinable.
180
209
  */
181
- export function parseFeishuMediaDurationMs(
210
+ export async function parseFeishuMediaDurationMs(
182
211
  buffer: Buffer,
183
212
  fileType: "opus" | "mp4",
184
- ): number | undefined {
213
+ options?: { fileName?: string; contentType?: string },
214
+ ): Promise<number | undefined> {
185
215
  if (fileType === "mp4") {
186
216
  return parseMp4DurationMs(buffer);
187
217
  }
188
- // fileType === "opus": try each container format in likelihood order
189
- return parseOggDurationMs(buffer) ?? parseMp4DurationMs(buffer) ?? parseWavDurationMs(buffer);
218
+ return (
219
+ await parseAudioDurationWithMusicMetadata({
220
+ buffer,
221
+ fileName: options?.fileName,
222
+ contentType: options?.contentType,
223
+ })
224
+ ) ?? parseOggDurationMs(buffer) ?? parseMp4DurationMs(buffer) ?? parseWavDurationMs(buffer);
190
225
  }
package/src/media.ts CHANGED
@@ -192,6 +192,12 @@ export type SendMediaResult = {
192
192
  chatId: string;
193
193
  };
194
194
 
195
+ function assertNonEmptyMediaBuffer(buffer: Buffer, name: string): void {
196
+ if (buffer.length === 0) {
197
+ throw new Error(`Feishu media upload failed: "${name}" is empty (0 bytes)`);
198
+ }
199
+ }
200
+
195
201
  /**
196
202
  * Upload an image to Feishu and get an image_key for sending.
197
203
  * Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
@@ -210,15 +216,16 @@ export async function uploadImageFeishu(params: {
210
216
 
211
217
  const client = createFeishuClient(account);
212
218
 
213
- // SDK expects a Readable stream, not a Buffer
214
- // Use type assertion since SDK actually accepts any Readable at runtime
215
- const imageStream =
216
- typeof image === "string" ? fs.createReadStream(image) : Readable.from(image);
219
+ if (Buffer.isBuffer(image)) {
220
+ assertNonEmptyMediaBuffer(image, "image");
221
+ }
222
+
223
+ const imagePayload = typeof image === "string" ? fs.createReadStream(image) : image;
217
224
 
218
225
  const response = await client.im.image.create({
219
226
  data: {
220
227
  image_type: imageType,
221
- image: imageStream as any,
228
+ image: imagePayload as any,
222
229
  },
223
230
  });
224
231
 
@@ -258,16 +265,17 @@ export async function uploadFileFeishu(params: {
258
265
 
259
266
  const client = createFeishuClient(account);
260
267
 
261
- // SDK expects a Readable stream, not a Buffer
262
- // Use type assertion since SDK actually accepts any Readable at runtime
263
- const fileStream =
264
- typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
268
+ if (Buffer.isBuffer(file)) {
269
+ assertNonEmptyMediaBuffer(file, fileName);
270
+ }
271
+
272
+ const filePayload = typeof file === "string" ? fs.createReadStream(file) : file;
265
273
 
266
274
  const response = await client.im.file.create({
267
275
  data: {
268
276
  file_type: fileType,
269
277
  file_name: fileName,
270
- file: fileStream as any,
278
+ file: filePayload as any,
271
279
  ...(duration !== undefined && { duration }),
272
280
  },
273
281
  });
@@ -506,12 +514,21 @@ export async function sendMediaFeishu(params: {
506
514
  ...(mergedLocalRoots.length > 0 ? { localRoots: mergedLocalRoots } : {}),
507
515
  });
508
516
  buffer = loaded.buffer;
509
- name = fileName ?? loaded.fileName ?? "file";
517
+ const loadedFileName = loaded.fileName ?? "file";
518
+ let decoded: string;
519
+ try {
520
+ decoded = decodeURIComponent(loadedFileName);
521
+ } catch {
522
+ decoded = loadedFileName;
523
+ }
524
+ name = fileName ?? decoded;
510
525
  contentType = loaded.contentType;
511
526
  } else {
512
527
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
513
528
  }
514
529
 
530
+ assertNonEmptyMediaBuffer(buffer, name);
531
+
515
532
  // Determine if it's an image based on extension
516
533
  const ext = path.extname(name).toLowerCase();
517
534
  const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
@@ -530,7 +547,7 @@ export async function sendMediaFeishu(params: {
530
547
  const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
531
548
  const duration =
532
549
  fileType === "opus" || fileType === "mp4"
533
- ? parseFeishuMediaDurationMs(buffer, fileType)
550
+ ? await parseFeishuMediaDurationMs(buffer, fileType, { fileName: name, contentType })
534
551
  : undefined;
535
552
  const { fileKey } = await uploadFileFeishu({
536
553
  cfg,
@@ -0,0 +1,154 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { runFeishuApiCall, type FeishuApiResponse } from "../tools-common/feishu-api.js";
3
+ import type { FeishuMessageParams } from "./schemas.js";
4
+
5
+ interface MessageGetResponse extends FeishuApiResponse {
6
+ data?: {
7
+ items?: Array<{
8
+ message_id?: string;
9
+ root_id?: string;
10
+ parent_id?: string;
11
+ msg_type?: string;
12
+ create_time?: string;
13
+ update_time?: string;
14
+ deleted?: boolean;
15
+ chat_id?: string;
16
+ sender?: { id: string; id_type: string; sender_type: string };
17
+ body?: { content: string };
18
+ mentions?: Array<{ key: string; id: string; id_type: string; name: string }>;
19
+ }>;
20
+ };
21
+ }
22
+
23
+ interface MessageListResponse extends FeishuApiResponse {
24
+ data?: {
25
+ has_more?: boolean;
26
+ page_token?: string;
27
+ items?: Array<{
28
+ message_id?: string;
29
+ root_id?: string;
30
+ parent_id?: string;
31
+ msg_type?: string;
32
+ create_time?: string;
33
+ deleted?: boolean;
34
+ chat_id?: string;
35
+ sender?: { id: string; id_type: string; sender_type: string };
36
+ body?: { content: string };
37
+ }>;
38
+ };
39
+ }
40
+
41
+ export async function runMessageAction(
42
+ client: Lark.Client,
43
+ params: FeishuMessageParams,
44
+ ): Promise<unknown> {
45
+ switch (params.action) {
46
+ case "get":
47
+ return getMessage(client, params);
48
+ case "list":
49
+ return listMessages(client, params);
50
+ }
51
+ }
52
+
53
+ function parseMessageContent(msgType: string | undefined, rawContent: string | undefined): string {
54
+ if (!rawContent) return "";
55
+ try {
56
+ const parsed = JSON.parse(rawContent);
57
+ if (msgType === "text") return (parsed.text as string) ?? rawContent;
58
+ if (msgType === "post") {
59
+ const title = (parsed.title as string) ?? "";
60
+ const lines: string[] = [];
61
+ for (const paragraph of (parsed.content as Array<Array<{ tag: string; text?: string }>>) ?? []) {
62
+ lines.push(paragraph.map((el) => el.text ?? "").join(""));
63
+ }
64
+ return title ? `${title}\n${lines.join("\n")}` : lines.join("\n");
65
+ }
66
+ if (msgType === "image") return "[image]";
67
+ if (msgType === "file") return `[file: ${(parsed.file_name as string) ?? ""}]`;
68
+ if (msgType === "audio") return "[audio]";
69
+ if (msgType === "sticker") return "[sticker]";
70
+ if (msgType === "share_chat") return "[share_chat]";
71
+ if (msgType === "share_user") return "[share_user]";
72
+ return `[${msgType ?? "unknown"}]`;
73
+ } catch {
74
+ return rawContent;
75
+ }
76
+ }
77
+
78
+ async function getMessage(client: Lark.Client, params: FeishuMessageParams) {
79
+ if (!params.message_id) {
80
+ throw new Error("message_id is required for action=get");
81
+ }
82
+
83
+ const response = await runFeishuApiCall<MessageGetResponse>(
84
+ "Feishu get message",
85
+ () =>
86
+ client.im.message.get({
87
+ path: { message_id: params.message_id! },
88
+ }) as Promise<MessageGetResponse>,
89
+ );
90
+
91
+ const item = response.data?.items?.[0];
92
+ if (!item) {
93
+ return { ok: true, action: "get", message_id: params.message_id, found: false };
94
+ }
95
+
96
+ return {
97
+ ok: true,
98
+ action: "get",
99
+ found: true,
100
+ message_id: item.message_id ?? params.message_id,
101
+ msg_type: item.msg_type ?? "",
102
+ content: parseMessageContent(item.msg_type, item.body?.content),
103
+ sender_id: item.sender?.id ?? "",
104
+ sender_type: item.sender?.sender_type ?? "",
105
+ chat_id: item.chat_id ?? "",
106
+ create_time: item.create_time ?? "",
107
+ update_time: item.update_time ?? "",
108
+ mentions: item.mentions?.map((m) => ({ id: m.id, name: m.name })) ?? [],
109
+ };
110
+ }
111
+
112
+ async function listMessages(client: Lark.Client, params: FeishuMessageParams) {
113
+ if (!params.chat_id) {
114
+ throw new Error("chat_id is required for action=list");
115
+ }
116
+
117
+ const pageSize = Math.min(Math.max(params.page_size ?? 10, 1), 50);
118
+ const sortType = params.sort_type ?? "ByCreateTimeDesc";
119
+
120
+ const response = await runFeishuApiCall<MessageListResponse>(
121
+ "Feishu list messages",
122
+ () =>
123
+ client.im.message.list({
124
+ params: {
125
+ container_id_type: "chat",
126
+ container_id: params.chat_id!,
127
+ sort_type: sortType,
128
+ page_size: pageSize,
129
+ ...(params.start_time ? { start_time: params.start_time } : {}),
130
+ ...(params.end_time ? { end_time: params.end_time } : {}),
131
+ },
132
+ }) as Promise<MessageListResponse>,
133
+ );
134
+
135
+ const messages = (response.data?.items ?? [])
136
+ .filter((item) => !item.deleted)
137
+ .map((item) => ({
138
+ message_id: item.message_id ?? "",
139
+ msg_type: item.msg_type ?? "",
140
+ content_preview: parseMessageContent(item.msg_type, item.body?.content),
141
+ sender_id: item.sender?.id ?? "",
142
+ sender_type: item.sender?.sender_type ?? "",
143
+ create_time: item.create_time ?? "",
144
+ chat_id: item.chat_id ?? "",
145
+ }));
146
+
147
+ return {
148
+ ok: true,
149
+ action: "list",
150
+ chat_id: params.chat_id,
151
+ total: messages.length,
152
+ messages,
153
+ };
154
+ }
@@ -0,0 +1,2 @@
1
+ export { registerFeishuMessageTools } from "./register.js";
2
+ export { FeishuMessageSchema, type FeishuMessageParams } from "./schemas.js";
@@ -0,0 +1,58 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
3
+ import { getCurrentFeishuToolContext } from "../tools-common/tool-context.js";
4
+ import { errorResult, json } from "../tools-common/feishu-api.js";
5
+ import { runMessageAction } from "./actions.js";
6
+ import { FeishuMessageSchema, type FeishuMessageParams } from "./schemas.js";
7
+
8
+ export function registerFeishuMessageTools(api: OpenClawPluginApi) {
9
+ if (!api.config) {
10
+ api.logger.debug?.("feishu_message: No config available, skipping");
11
+ return;
12
+ }
13
+
14
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
15
+ api.logger.debug?.("feishu_message: No Feishu accounts configured, skipping");
16
+ return;
17
+ }
18
+
19
+ if (!hasFeishuToolEnabledForAnyAccount(api.config, "message")) {
20
+ api.logger.debug?.("feishu_message: message tool disabled in config");
21
+ return;
22
+ }
23
+
24
+ api.registerTool(
25
+ {
26
+ name: "feishu_message",
27
+ label: "Feishu Message",
28
+ description:
29
+ "Read Feishu messages. Actions: get (get a single message by message_id), list (list recent messages in a chat). " +
30
+ "Use list to discover message_ids for use with other tools (e.g., feishu_reaction).",
31
+ parameters: FeishuMessageSchema,
32
+ async execute(_toolCallId, params) {
33
+ const p = params as FeishuMessageParams;
34
+ if (p.action === "list") {
35
+ const isValidChatId = p.chat_id?.startsWith("oc_");
36
+ if (!isValidChatId) {
37
+ const ctx = getCurrentFeishuToolContext();
38
+ if (ctx?.chatId) p.chat_id = ctx.chatId;
39
+ }
40
+ }
41
+
42
+ try {
43
+ return await withFeishuToolClient({
44
+ api,
45
+ toolName: "feishu_message",
46
+ requiredTool: "message",
47
+ run: async ({ client }) => json(await runMessageAction(client, p)),
48
+ });
49
+ } catch (err) {
50
+ return errorResult(err);
51
+ }
52
+ },
53
+ },
54
+ { name: "feishu_message" },
55
+ );
56
+
57
+ api.logger.debug?.("feishu_message: Registered feishu_message tool");
58
+ }
@@ -0,0 +1,56 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
3
+ function stringEnum<T extends readonly string[]>(
4
+ values: T,
5
+ options: { description?: string; default?: T[number] } = {},
6
+ ) {
7
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
8
+ }
9
+
10
+ const ACTION_VALUES = ["get", "list"] as const;
11
+ const SORT_VALUES = ["ByCreateTimeAsc", "ByCreateTimeDesc"] as const;
12
+
13
+ export const FeishuMessageSchema = Type.Object({
14
+ action: stringEnum(ACTION_VALUES, {
15
+ description:
16
+ "Action to perform: get (get a single message by message_id), list (list recent messages in a chat by chat_id).",
17
+ }),
18
+ message_id: Type.Optional(
19
+ Type.String({
20
+ description: "Feishu message ID (e.g. om_xxx). Required for action=get.",
21
+ }),
22
+ ),
23
+ chat_id: Type.Optional(
24
+ Type.String({
25
+ description: "Chat ID (e.g. oc_xxx). Required for action=list. Omit to use the current conversation's chat.",
26
+ }),
27
+ ),
28
+ page_size: Type.Optional(
29
+ Type.Integer({
30
+ description: "Number of messages to fetch for action=list (default: 10, max: 50).",
31
+ minimum: 1,
32
+ maximum: 50,
33
+ default: 10,
34
+ }),
35
+ ),
36
+ sort_type: Type.Optional(
37
+ stringEnum(SORT_VALUES, {
38
+ description: "Sort order for action=list. Default: ByCreateTimeDesc (newest first).",
39
+ default: "ByCreateTimeDesc",
40
+ }),
41
+ ),
42
+ start_time: Type.Optional(
43
+ Type.String({
44
+ description:
45
+ "Start of time range for action=list, as Unix timestamp in seconds (e.g. \"1710000000\"). Omit for no lower bound.",
46
+ }),
47
+ ),
48
+ end_time: Type.Optional(
49
+ Type.String({
50
+ description:
51
+ "End of time range for action=list, as Unix timestamp in seconds (e.g. \"1710086400\"). Omit for no upper bound.",
52
+ }),
53
+ ),
54
+ });
55
+
56
+ export type FeishuMessageParams = Static<typeof FeishuMessageSchema>;
package/src/onboarding.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
 
16
16
  import {
17
17
  listFeishuAccountIds,
18
+ resolveConfiguredFeishuAccountKey,
18
19
  resolveDefaultFeishuAccountId,
19
20
  resolveFeishuAccount,
20
21
  resolveFeishuCredentials,
@@ -58,7 +59,8 @@ function upsertFeishuAccountConfig(
58
59
  };
59
60
  }
60
61
 
61
- const existingAccount = feishuCfg?.accounts?.[normalizedAccountId];
62
+ const accountKey = resolveConfiguredFeishuAccountKey(cfg, normalizedAccountId) ?? normalizedAccountId;
63
+ const existingAccount = feishuCfg?.accounts?.[accountKey];
62
64
  return {
63
65
  ...cfg,
64
66
  channels: {
@@ -68,7 +70,7 @@ function upsertFeishuAccountConfig(
68
70
  enabled: true,
69
71
  accounts: {
70
72
  ...feishuCfg?.accounts,
71
- [normalizedAccountId]: {
73
+ [accountKey]: {
72
74
  ...existingAccount,
73
75
  enabled: existingAccount?.enabled ?? true,
74
76
  ...patch,
@@ -0,0 +1,127 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { runFeishuApiCall, type FeishuApiResponse } from "../tools-common/feishu-api.js";
3
+ import type { FeishuReactionParams } from "./schemas.js";
4
+
5
+ interface ReactionCreateResponse extends FeishuApiResponse {
6
+ data?: { reaction_id?: string };
7
+ }
8
+
9
+ interface ReactionListResponse extends FeishuApiResponse {
10
+ data?: {
11
+ items?: Array<{
12
+ reaction_id?: string;
13
+ reaction_type?: { emoji_type?: string };
14
+ operator_type?: string;
15
+ operator_id?: { open_id?: string; user_id?: string; union_id?: string };
16
+ }>;
17
+ has_more?: boolean;
18
+ page_token?: string;
19
+ };
20
+ }
21
+
22
+ export async function runReactionAction(
23
+ client: Lark.Client,
24
+ params: FeishuReactionParams,
25
+ ): Promise<unknown> {
26
+ switch (params.action) {
27
+ case "add":
28
+ return addReaction(client, params);
29
+ case "remove":
30
+ return removeReaction(client, params);
31
+ case "list":
32
+ return listReactions(client, params);
33
+ }
34
+ }
35
+
36
+ async function addReaction(client: Lark.Client, params: FeishuReactionParams) {
37
+ if (!params.emoji_type) {
38
+ throw new Error("emoji_type is required for action=add");
39
+ }
40
+
41
+ const response = await runFeishuApiCall<ReactionCreateResponse>(
42
+ "Feishu add reaction",
43
+ () =>
44
+ client.im.messageReaction.create({
45
+ path: { message_id: params.message_id },
46
+ data: { reaction_type: { emoji_type: params.emoji_type! } },
47
+ }) as Promise<ReactionCreateResponse>,
48
+ );
49
+
50
+ return {
51
+ ok: true,
52
+ action: "add",
53
+ message_id: params.message_id,
54
+ emoji_type: params.emoji_type,
55
+ reaction_id: response.data?.reaction_id ?? null,
56
+ };
57
+ }
58
+
59
+ async function removeReaction(client: Lark.Client, params: FeishuReactionParams) {
60
+ if (!params.reaction_id) {
61
+ throw new Error("reaction_id is required for action=remove");
62
+ }
63
+
64
+ await runFeishuApiCall<FeishuApiResponse>(
65
+ "Feishu remove reaction",
66
+ () =>
67
+ client.im.messageReaction.delete({
68
+ path: {
69
+ message_id: params.message_id,
70
+ reaction_id: params.reaction_id!,
71
+ },
72
+ }) as Promise<FeishuApiResponse>,
73
+ );
74
+
75
+ return {
76
+ ok: true,
77
+ action: "remove",
78
+ message_id: params.message_id,
79
+ reaction_id: params.reaction_id,
80
+ };
81
+ }
82
+
83
+ async function listReactions(client: Lark.Client, params: FeishuReactionParams) {
84
+ const allItems: Array<{
85
+ reaction_id: string;
86
+ emoji_type: string;
87
+ operator_type: string;
88
+ operator_id: string;
89
+ }> = [];
90
+
91
+ let pageToken: string | undefined;
92
+ do {
93
+ const response = await runFeishuApiCall<ReactionListResponse>(
94
+ "Feishu list reactions",
95
+ () =>
96
+ client.im.messageReaction.list({
97
+ path: { message_id: params.message_id },
98
+ params: {
99
+ ...(params.emoji_type ? { reaction_type: params.emoji_type } : {}),
100
+ ...(pageToken ? { page_token: pageToken } : {}),
101
+ page_size: 50,
102
+ },
103
+ }) as Promise<ReactionListResponse>,
104
+ );
105
+
106
+ for (const item of response.data?.items ?? []) {
107
+ allItems.push({
108
+ reaction_id: item.reaction_id ?? "",
109
+ emoji_type: item.reaction_type?.emoji_type ?? "",
110
+ operator_type: item.operator_type ?? "user",
111
+ operator_id:
112
+ item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
113
+ });
114
+ }
115
+
116
+ pageToken = response.data?.has_more ? (response.data.page_token ?? undefined) : undefined;
117
+ } while (pageToken);
118
+
119
+ return {
120
+ ok: true,
121
+ action: "list",
122
+ message_id: params.message_id,
123
+ emoji_type_filter: params.emoji_type ?? null,
124
+ total: allItems.length,
125
+ reactions: allItems,
126
+ };
127
+ }
@@ -0,0 +1,2 @@
1
+ export { registerFeishuReactionTools } from "./register.js";
2
+ export { FeishuReactionSchema, type FeishuReactionParams } from "./schemas.js";
@@ -0,0 +1,51 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
3
+ import { errorResult, json } from "../tools-common/feishu-api.js";
4
+ import { runReactionAction } from "./actions.js";
5
+ import { FeishuReactionSchema, type FeishuReactionParams } from "./schemas.js";
6
+
7
+ export function registerFeishuReactionTools(api: OpenClawPluginApi) {
8
+ if (!api.config) {
9
+ api.logger.debug?.("feishu_reaction: No config available, skipping");
10
+ return;
11
+ }
12
+
13
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
14
+ api.logger.debug?.("feishu_reaction: No Feishu accounts configured, skipping");
15
+ return;
16
+ }
17
+
18
+ if (!hasFeishuToolEnabledForAnyAccount(api.config, "reaction")) {
19
+ api.logger.debug?.("feishu_reaction: reaction tool disabled in config");
20
+ return;
21
+ }
22
+
23
+ api.registerTool(
24
+ {
25
+ name: "feishu_reaction",
26
+ label: "Feishu Reaction",
27
+ description:
28
+ "Add, remove, or list emoji reactions on Feishu messages. " +
29
+ "Supported actions: add (add an emoji reaction), remove (remove a reaction by ID), list (list all reactions). " +
30
+ "Common emoji types: THUMBSUP, THUMBSDOWN, HEART, SMILE, GRINNING, FIRE, CLAP, OK, CHECK, CROSS, PARTY, PRAY. " +
31
+ "To react to a previous message, first use feishu_message list to find the target message_id.",
32
+ parameters: FeishuReactionSchema,
33
+ async execute(_toolCallId, params) {
34
+ const p = params as FeishuReactionParams;
35
+ try {
36
+ return await withFeishuToolClient({
37
+ api,
38
+ toolName: "feishu_reaction",
39
+ requiredTool: "reaction",
40
+ run: async ({ client }) => json(await runReactionAction(client, p)),
41
+ });
42
+ } catch (err) {
43
+ return errorResult(err);
44
+ }
45
+ },
46
+ },
47
+ { name: "feishu_reaction" },
48
+ );
49
+
50
+ api.logger.debug?.("feishu_reaction: Registered feishu_reaction tool");
51
+ }
@@ -0,0 +1,34 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
3
+ function stringEnum<T extends readonly string[]>(
4
+ values: T,
5
+ options: { description?: string; default?: T[number] } = {},
6
+ ) {
7
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
8
+ }
9
+
10
+ const ACTION_VALUES = ["add", "remove", "list"] as const;
11
+
12
+ export const FeishuReactionSchema = Type.Object({
13
+ action: stringEnum(ACTION_VALUES, {
14
+ description:
15
+ "Action to perform: add (add emoji reaction to a message), remove (remove a reaction by reaction_id), list (list all reactions on a message).",
16
+ }),
17
+ message_id: Type.String({
18
+ description: "Feishu message ID (e.g. om_xxx). Use feishu_message list to find message_ids from chat history.",
19
+ }),
20
+ emoji_type: Type.Optional(
21
+ Type.String({
22
+ description:
23
+ "Emoji type to add or filter by (e.g. THUMBSUP, HEART, SMILE, GRINNING, FIRE, CLAP, OK, CHECK, CROSS). " +
24
+ "Required for action=add. Optional filter for action=list.",
25
+ }),
26
+ ),
27
+ reaction_id: Type.Optional(
28
+ Type.String({
29
+ description: "Reaction ID to remove. Required for action=remove. Obtained from add or list results.",
30
+ }),
31
+ ),
32
+ });
33
+
34
+ export type FeishuReactionParams = Static<typeof FeishuReactionSchema>;