@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.
- package/README.md +28 -0
- package/index.ts +4 -0
- package/package.json +2 -1
- package/skills/feishu-message/SKILL.md +111 -0
- package/skills/feishu-reaction/SKILL.md +120 -0
- package/src/accounts.ts +27 -5
- package/src/bot.ts +18 -5
- package/src/channel.ts +12 -7
- package/src/config-schema.ts +22 -0
- package/src/drive-tools/register.ts +1 -1
- package/src/media-duration.ts +43 -8
- package/src/media.ts +29 -12
- package/src/message-tools/actions.ts +154 -0
- package/src/message-tools/index.ts +2 -0
- package/src/message-tools/register.ts +58 -0
- package/src/message-tools/schemas.ts +56 -0
- package/src/onboarding.ts +4 -2
- package/src/reaction-tools/actions.ts +127 -0
- package/src/reaction-tools/index.ts +2 -0
- package/src/reaction-tools/register.ts +51 -0
- package/src/reaction-tools/schemas.ts +34 -0
- package/src/reply-dispatcher.ts +23 -33
- package/src/streaming-card.ts +34 -11
- package/src/text/markdown-links.ts +16 -1
- package/src/tools-common/tool-context.ts +1 -0
- package/src/tools-config.ts +2 -0
- package/src/types.ts +4 -0
package/src/media-duration.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
213
|
+
options?: { fileName?: string; contentType?: string },
|
|
214
|
+
): Promise<number | undefined> {
|
|
185
215
|
if (fileType === "mp4") {
|
|
186
216
|
return parseMp4DurationMs(buffer);
|
|
187
217
|
}
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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:
|
|
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
|
-
|
|
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,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
|
|
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
|
-
[
|
|
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,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>;
|