@openclaw-plugins/feishu-plus 0.1.7

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/src/send.ts ADDED
@@ -0,0 +1,360 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
3
+ import type { MentionTarget } from "./mention.js";
4
+ import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
5
+ import { createFeishuClient } from "./client.js";
6
+ import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
7
+ import { getFeishuRuntime } from "./runtime.js";
8
+ import { resolveFeishuAccount } from "./accounts.js";
9
+
10
+ export type FeishuMessageInfo = {
11
+ messageId: string;
12
+ chatId: string;
13
+ senderId?: string;
14
+ senderOpenId?: string;
15
+ content: string;
16
+ contentType: string;
17
+ createTime?: number;
18
+ };
19
+
20
+ /**
21
+ * Get a message by its ID.
22
+ * Useful for fetching quoted/replied message content.
23
+ */
24
+ export async function getMessageFeishu(params: {
25
+ cfg: ClawdbotConfig;
26
+ messageId: string;
27
+ accountId?: string;
28
+ }): Promise<FeishuMessageInfo | null> {
29
+ const { cfg, messageId, accountId } = params;
30
+ const account = resolveFeishuAccount({ cfg, accountId });
31
+ if (!account.configured) {
32
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
33
+ }
34
+
35
+ const client = createFeishuClient(account);
36
+
37
+ try {
38
+ const response = (await client.im.message.get({
39
+ path: { message_id: messageId },
40
+ })) as {
41
+ code?: number;
42
+ msg?: string;
43
+ data?: {
44
+ items?: Array<{
45
+ message_id?: string;
46
+ chat_id?: string;
47
+ msg_type?: string;
48
+ body?: { content?: string };
49
+ sender?: {
50
+ id?: string;
51
+ id_type?: string;
52
+ sender_type?: string;
53
+ };
54
+ create_time?: string;
55
+ }>;
56
+ };
57
+ };
58
+
59
+ if (response.code !== 0) {
60
+ return null;
61
+ }
62
+
63
+ const item = response.data?.items?.[0];
64
+ if (!item) {
65
+ return null;
66
+ }
67
+
68
+ // Parse content based on message type
69
+ let content = item.body?.content ?? "";
70
+ try {
71
+ const parsed = JSON.parse(content);
72
+ if (item.msg_type === "text" && parsed.text) {
73
+ content = parsed.text;
74
+ }
75
+ } catch {
76
+ // Keep raw content if parsing fails
77
+ }
78
+
79
+ return {
80
+ messageId: item.message_id ?? messageId,
81
+ chatId: item.chat_id ?? "",
82
+ senderId: item.sender?.id,
83
+ senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
84
+ content,
85
+ contentType: item.msg_type ?? "text",
86
+ createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
87
+ };
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ export type SendFeishuMessageParams = {
94
+ cfg: ClawdbotConfig;
95
+ to: string;
96
+ text: string;
97
+ replyToMessageId?: string;
98
+ /** Mention target users */
99
+ mentions?: MentionTarget[];
100
+ /** Account ID (optional, uses default if not specified) */
101
+ accountId?: string;
102
+ };
103
+
104
+ function buildFeishuPostMessagePayload(params: { messageText: string }): {
105
+ content: string;
106
+ msgType: string;
107
+ } {
108
+ const { messageText } = params;
109
+ return {
110
+ content: JSON.stringify({
111
+ zh_cn: {
112
+ content: [
113
+ [
114
+ {
115
+ tag: "md",
116
+ text: messageText,
117
+ },
118
+ ],
119
+ ],
120
+ },
121
+ }),
122
+ msgType: "post",
123
+ };
124
+ }
125
+
126
+ export async function sendMessageFeishu(params: SendFeishuMessageParams): Promise<FeishuSendResult> {
127
+ const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
128
+ const account = resolveFeishuAccount({ cfg, accountId });
129
+ if (!account.configured) {
130
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
131
+ }
132
+
133
+ const client = createFeishuClient(account);
134
+ const receiveId = normalizeFeishuTarget(to);
135
+ if (!receiveId) {
136
+ throw new Error(`Invalid Feishu target: ${to}`);
137
+ }
138
+
139
+ const receiveIdType = resolveReceiveIdType(receiveId);
140
+ const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
141
+ cfg,
142
+ channel: "feishu",
143
+ });
144
+
145
+ // Build message content (with @mention support)
146
+ let rawText = text ?? "";
147
+ if (mentions && mentions.length > 0) {
148
+ rawText = buildMentionedMessage(mentions, rawText);
149
+ }
150
+ const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode);
151
+
152
+ const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
153
+
154
+ if (replyToMessageId) {
155
+ const response = await client.im.message.reply({
156
+ path: { message_id: replyToMessageId },
157
+ data: {
158
+ content,
159
+ msg_type: msgType,
160
+ },
161
+ });
162
+
163
+ if (response.code !== 0) {
164
+ throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
165
+ }
166
+
167
+ return {
168
+ messageId: response.data?.message_id ?? "unknown",
169
+ chatId: receiveId,
170
+ };
171
+ }
172
+
173
+ const response = await client.im.message.create({
174
+ params: { receive_id_type: receiveIdType },
175
+ data: {
176
+ receive_id: receiveId,
177
+ content,
178
+ msg_type: msgType,
179
+ },
180
+ });
181
+
182
+ if (response.code !== 0) {
183
+ throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
184
+ }
185
+
186
+ return {
187
+ messageId: response.data?.message_id ?? "unknown",
188
+ chatId: receiveId,
189
+ };
190
+ }
191
+
192
+ export type SendFeishuCardParams = {
193
+ cfg: ClawdbotConfig;
194
+ to: string;
195
+ card: Record<string, unknown>;
196
+ replyToMessageId?: string;
197
+ accountId?: string;
198
+ };
199
+
200
+ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
201
+ const { cfg, to, card, replyToMessageId, accountId } = params;
202
+ const account = resolveFeishuAccount({ cfg, accountId });
203
+ if (!account.configured) {
204
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
205
+ }
206
+
207
+ const client = createFeishuClient(account);
208
+ const receiveId = normalizeFeishuTarget(to);
209
+ if (!receiveId) {
210
+ throw new Error(`Invalid Feishu target: ${to}`);
211
+ }
212
+
213
+ const receiveIdType = resolveReceiveIdType(receiveId);
214
+ const content = JSON.stringify(card);
215
+
216
+ if (replyToMessageId) {
217
+ const response = await client.im.message.reply({
218
+ path: { message_id: replyToMessageId },
219
+ data: {
220
+ content,
221
+ msg_type: "interactive",
222
+ },
223
+ });
224
+
225
+ if (response.code !== 0) {
226
+ throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
227
+ }
228
+
229
+ return {
230
+ messageId: response.data?.message_id ?? "unknown",
231
+ chatId: receiveId,
232
+ };
233
+ }
234
+
235
+ const response = await client.im.message.create({
236
+ params: { receive_id_type: receiveIdType },
237
+ data: {
238
+ receive_id: receiveId,
239
+ content,
240
+ msg_type: "interactive",
241
+ },
242
+ });
243
+
244
+ if (response.code !== 0) {
245
+ throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
246
+ }
247
+
248
+ return {
249
+ messageId: response.data?.message_id ?? "unknown",
250
+ chatId: receiveId,
251
+ };
252
+ }
253
+
254
+ export async function updateCardFeishu(params: {
255
+ cfg: ClawdbotConfig;
256
+ messageId: string;
257
+ card: Record<string, unknown>;
258
+ accountId?: string;
259
+ }): Promise<void> {
260
+ const { cfg, messageId, card, accountId } = params;
261
+ const account = resolveFeishuAccount({ cfg, accountId });
262
+ if (!account.configured) {
263
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
264
+ }
265
+
266
+ const client = createFeishuClient(account);
267
+ const content = JSON.stringify(card);
268
+
269
+ const response = await client.im.message.patch({
270
+ path: { message_id: messageId },
271
+ data: { content },
272
+ });
273
+
274
+ if (response.code !== 0) {
275
+ throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Build a Feishu interactive card with markdown content.
281
+ * Cards render markdown properly (code blocks, tables, links, etc.)
282
+ * Uses schema 2.0 format for proper markdown rendering.
283
+ */
284
+ export function buildMarkdownCard(text: string): Record<string, unknown> {
285
+ return {
286
+ schema: "2.0",
287
+ config: {
288
+ wide_screen_mode: true,
289
+ },
290
+ body: {
291
+ elements: [
292
+ {
293
+ tag: "markdown",
294
+ content: text,
295
+ },
296
+ ],
297
+ },
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Send a message as a markdown card (interactive message).
303
+ * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
304
+ */
305
+ export async function sendMarkdownCardFeishu(params: {
306
+ cfg: ClawdbotConfig;
307
+ to: string;
308
+ text: string;
309
+ replyToMessageId?: string;
310
+ /** Mention target users */
311
+ mentions?: MentionTarget[];
312
+ accountId?: string;
313
+ }): Promise<FeishuSendResult> {
314
+ const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
315
+ // Build message content (with @mention support)
316
+ let cardText = text;
317
+ if (mentions && mentions.length > 0) {
318
+ cardText = buildMentionedCardContent(mentions, text);
319
+ }
320
+ const card = buildMarkdownCard(cardText);
321
+ return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId });
322
+ }
323
+
324
+ /**
325
+ * Edit an existing text message.
326
+ * Note: Feishu only allows editing messages within 24 hours.
327
+ */
328
+ export async function editMessageFeishu(params: {
329
+ cfg: ClawdbotConfig;
330
+ messageId: string;
331
+ text: string;
332
+ accountId?: string;
333
+ }): Promise<void> {
334
+ const { cfg, messageId, text, accountId } = params;
335
+ const account = resolveFeishuAccount({ cfg, accountId });
336
+ if (!account.configured) {
337
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
338
+ }
339
+
340
+ const client = createFeishuClient(account);
341
+ const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
342
+ cfg,
343
+ channel: "feishu",
344
+ });
345
+ const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
346
+
347
+ const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
348
+
349
+ const response = await client.im.message.update({
350
+ path: { message_id: messageId },
351
+ data: {
352
+ msg_type: msgType,
353
+ content,
354
+ },
355
+ });
356
+
357
+ if (response.code !== 0) {
358
+ throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
359
+ }
360
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { FeishuIdType } from "./types.js";
2
+
3
+ const CHAT_ID_PREFIX = "oc_";
4
+ const OPEN_ID_PREFIX = "ou_";
5
+ const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
6
+
7
+ export function detectIdType(id: string): FeishuIdType | null {
8
+ const trimmed = id.trim();
9
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
10
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
11
+ if (USER_ID_REGEX.test(trimmed)) return "user_id";
12
+ return null;
13
+ }
14
+
15
+ export function normalizeFeishuTarget(raw: string): string | null {
16
+ const trimmed = raw.trim();
17
+ if (!trimmed) return null;
18
+
19
+ const lowered = trimmed.toLowerCase();
20
+ if (lowered.startsWith("chat:")) {
21
+ return trimmed.slice("chat:".length).trim() || null;
22
+ }
23
+ if (lowered.startsWith("user:")) {
24
+ return trimmed.slice("user:".length).trim() || null;
25
+ }
26
+ if (lowered.startsWith("open_id:")) {
27
+ return trimmed.slice("open_id:".length).trim() || null;
28
+ }
29
+
30
+ return trimmed;
31
+ }
32
+
33
+ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
34
+ const trimmed = id.trim();
35
+ if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) {
36
+ return `chat:${trimmed}`;
37
+ }
38
+ if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) {
39
+ return `user:${trimmed}`;
40
+ }
41
+ return trimmed;
42
+ }
43
+
44
+ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
45
+ const trimmed = id.trim();
46
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
47
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
48
+ return "open_id";
49
+ }
50
+
51
+ export function looksLikeFeishuId(raw: string): boolean {
52
+ const trimmed = raw.trim();
53
+ if (!trimmed) return false;
54
+ if (/^(chat|user|open_id):/i.test(trimmed)) return true;
55
+ if (trimmed.startsWith(CHAT_ID_PREFIX)) return true;
56
+ if (trimmed.startsWith(OPEN_ID_PREFIX)) return true;
57
+ return false;
58
+ }
@@ -0,0 +1,21 @@
1
+ import type { FeishuToolsConfig } from "./types.js";
2
+
3
+ /**
4
+ * Default tool configuration.
5
+ * - doc, wiki, drive, scopes: enabled by default
6
+ * - perm: disabled by default (sensitive operation)
7
+ */
8
+ export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
9
+ doc: true,
10
+ wiki: true,
11
+ drive: true,
12
+ perm: false,
13
+ scopes: true,
14
+ };
15
+
16
+ /**
17
+ * Resolve tools config with defaults.
18
+ */
19
+ export function resolveToolsConfig(cfg?: FeishuToolsConfig): Required<FeishuToolsConfig> {
20
+ return { ...DEFAULT_TOOLS_CONFIG, ...cfg };
21
+ }
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { FeishuConfigSchema, FeishuGroupSchema, FeishuAccountConfigSchema, z } from "./config-schema.js";
2
+ import type { MentionTarget } from "./mention.js";
3
+
4
+ export type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
5
+ export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
6
+ export type FeishuAccountConfig = z.infer<typeof FeishuAccountConfigSchema>;
7
+
8
+ export type FeishuDomain = "feishu" | "lark" | (string & {});
9
+ export type FeishuConnectionMode = "websocket" | "webhook";
10
+
11
+ export type ResolvedFeishuAccount = {
12
+ accountId: string;
13
+ enabled: boolean;
14
+ configured: boolean;
15
+ name?: string;
16
+ appId?: string;
17
+ appSecret?: string;
18
+ encryptKey?: string;
19
+ verificationToken?: string;
20
+ domain: FeishuDomain;
21
+ /** Merged config (top-level defaults + account-specific overrides) */
22
+ config: FeishuConfig;
23
+ };
24
+
25
+ export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id";
26
+
27
+ export type FeishuMessageContext = {
28
+ chatId: string;
29
+ messageId: string;
30
+ senderId: string;
31
+ senderOpenId: string;
32
+ senderName?: string;
33
+ chatType: "p2p" | "group";
34
+ mentionedBot: boolean;
35
+ rootId?: string;
36
+ parentId?: string;
37
+ content: string;
38
+ contentType: string;
39
+ /** Mention forward targets (excluding the bot itself) */
40
+ mentionTargets?: MentionTarget[];
41
+ /** Extracted message body (after removing @ placeholders) */
42
+ mentionMessageBody?: string;
43
+ };
44
+
45
+ export type FeishuSendResult = {
46
+ messageId: string;
47
+ chatId: string;
48
+ };
49
+
50
+ export type FeishuProbeResult = {
51
+ ok: boolean;
52
+ error?: string;
53
+ appId?: string;
54
+ botName?: string;
55
+ botOpenId?: string;
56
+ };
57
+
58
+ export type FeishuMediaInfo = {
59
+ path: string;
60
+ contentType?: string;
61
+ placeholder: string;
62
+ };
63
+
64
+ export type FeishuToolsConfig = {
65
+ doc?: boolean;
66
+ wiki?: boolean;
67
+ drive?: boolean;
68
+ perm?: boolean;
69
+ scopes?: boolean;
70
+ };
71
+
72
+ export type DynamicAgentCreationConfig = {
73
+ enabled?: boolean;
74
+ workspaceTemplate?: string;
75
+ agentDirTemplate?: string;
76
+ maxAgents?: number;
77
+ };
package/src/typing.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { createFeishuClient } from "./client.js";
3
+ import { resolveFeishuAccount } from "./accounts.js";
4
+
5
+ // Feishu emoji types for typing indicator
6
+ // See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
7
+ // Full list: https://github.com/go-lark/lark/blob/main/emoji.go
8
+ const TYPING_EMOJI = "Typing"; // Typing indicator emoji
9
+
10
+ export type TypingIndicatorState = {
11
+ messageId: string;
12
+ reactionId: string | null;
13
+ };
14
+
15
+ /**
16
+ * Add a typing indicator (reaction) to a message
17
+ */
18
+ export async function addTypingIndicator(params: {
19
+ cfg: ClawdbotConfig;
20
+ messageId: string;
21
+ accountId?: string;
22
+ }): Promise<TypingIndicatorState> {
23
+ const { cfg, messageId, accountId } = params;
24
+ const account = resolveFeishuAccount({ cfg, accountId });
25
+ if (!account.configured) {
26
+ return { messageId, reactionId: null };
27
+ }
28
+
29
+ const client = createFeishuClient(account);
30
+
31
+ try {
32
+ const response = await client.im.messageReaction.create({
33
+ path: { message_id: messageId },
34
+ data: {
35
+ reaction_type: { emoji_type: TYPING_EMOJI },
36
+ },
37
+ });
38
+
39
+ const reactionId = (response as any)?.data?.reaction_id ?? null;
40
+ return { messageId, reactionId };
41
+ } catch (err) {
42
+ // Silently fail - typing indicator is not critical
43
+ console.log(`[feishu] failed to add typing indicator: ${err}`);
44
+ return { messageId, reactionId: null };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Remove a typing indicator (reaction) from a message
50
+ */
51
+ export async function removeTypingIndicator(params: {
52
+ cfg: ClawdbotConfig;
53
+ state: TypingIndicatorState;
54
+ accountId?: string;
55
+ }): Promise<void> {
56
+ const { cfg, state, accountId } = params;
57
+ if (!state.reactionId) return;
58
+
59
+ const account = resolveFeishuAccount({ cfg, accountId });
60
+ if (!account.configured) return;
61
+
62
+ const client = createFeishuClient(account);
63
+
64
+ try {
65
+ await client.im.messageReaction.delete({
66
+ path: {
67
+ message_id: state.messageId,
68
+ reaction_id: state.reactionId,
69
+ },
70
+ });
71
+ } catch (err) {
72
+ // Silently fail - cleanup is not critical
73
+ console.log(`[feishu] failed to remove typing indicator: ${err}`);
74
+ }
75
+ }
@@ -0,0 +1,55 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
3
+ export const FeishuWikiSchema = Type.Union([
4
+ Type.Object({
5
+ action: Type.Literal("spaces"),
6
+ }),
7
+ Type.Object({
8
+ action: Type.Literal("nodes"),
9
+ space_id: Type.String({ description: "Knowledge space ID" }),
10
+ parent_node_token: Type.Optional(
11
+ Type.String({ description: "Parent node token (optional, omit for root)" }),
12
+ ),
13
+ }),
14
+ Type.Object({
15
+ action: Type.Literal("get"),
16
+ token: Type.String({ description: "Wiki node token (from URL /wiki/XXX)" }),
17
+ }),
18
+ Type.Object({
19
+ action: Type.Literal("search"),
20
+ query: Type.String({ description: "Search query" }),
21
+ space_id: Type.Optional(Type.String({ description: "Limit search to this space (optional)" })),
22
+ }),
23
+ Type.Object({
24
+ action: Type.Literal("create"),
25
+ space_id: Type.String({ description: "Knowledge space ID" }),
26
+ title: Type.String({ description: "Node title" }),
27
+ obj_type: Type.Optional(
28
+ Type.Union([Type.Literal("docx"), Type.Literal("sheet"), Type.Literal("bitable")], {
29
+ description: "Object type (default: docx)",
30
+ }),
31
+ ),
32
+ parent_node_token: Type.Optional(
33
+ Type.String({ description: "Parent node token (optional, omit for root)" }),
34
+ ),
35
+ }),
36
+ Type.Object({
37
+ action: Type.Literal("move"),
38
+ space_id: Type.String({ description: "Source knowledge space ID" }),
39
+ node_token: Type.String({ description: "Node token to move" }),
40
+ target_space_id: Type.Optional(
41
+ Type.String({ description: "Target space ID (optional, same space if omitted)" }),
42
+ ),
43
+ target_parent_token: Type.Optional(
44
+ Type.String({ description: "Target parent node token (optional, root if omitted)" }),
45
+ ),
46
+ }),
47
+ Type.Object({
48
+ action: Type.Literal("rename"),
49
+ space_id: Type.String({ description: "Knowledge space ID" }),
50
+ node_token: Type.String({ description: "Node token to rename" }),
51
+ title: Type.String({ description: "New title" }),
52
+ }),
53
+ ]);
54
+
55
+ export type FeishuWikiParams = Static<typeof FeishuWikiSchema>;