@openclaw-plugins/feishu-plus 0.1.7-fork.1
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/LICENSE +21 -0
- package/README.md +560 -0
- package/index.ts +74 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +65 -0
- package/skills/feishu-doc/SKILL.md +99 -0
- package/skills/feishu-doc/references/block-types.md +102 -0
- package/skills/feishu-drive/SKILL.md +96 -0
- package/skills/feishu-perm/SKILL.md +90 -0
- package/skills/feishu-wiki/SKILL.md +96 -0
- package/src/accounts.ts +140 -0
- package/src/bitable.ts +441 -0
- package/src/bot.ts +919 -0
- package/src/channel.ts +335 -0
- package/src/client.ts +114 -0
- package/src/config-schema.ts +199 -0
- package/src/directory.ts +165 -0
- package/src/doc-schema.ts +47 -0
- package/src/docx.ts +525 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +207 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/media.ts +523 -0
- package/src/mention.ts +121 -0
- package/src/monitor.ts +190 -0
- package/src/onboarding.ts +358 -0
- package/src/outbound.ts +40 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +166 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +115 -0
- package/src/reactions.ts +160 -0
- package/src/reply-dispatcher.ts +225 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +492 -0
- package/src/stream.ts +160 -0
- package/src/targets.ts +58 -0
- package/src/tools-config.ts +21 -0
- package/src/types.ts +77 -0
- package/src/typing.ts +75 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +224 -0
package/src/send.ts
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
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
|
+
* Card template color options
|
|
303
|
+
*/
|
|
304
|
+
export type CardTemplate = "blue" | "wathet" | "turquoise" | "green" | "yellow" | "orange" | "red" | "carmine" | "violet" | "purple" | "indigo" | "grey" | "default";
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Button configuration for interactive cards
|
|
308
|
+
*/
|
|
309
|
+
export interface CardButton {
|
|
310
|
+
text: string;
|
|
311
|
+
type?: "primary" | "default" | "danger";
|
|
312
|
+
url: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Build a Feishu interactive card with title, content and optional buttons.
|
|
317
|
+
* This creates a rich, visually appealing card message.
|
|
318
|
+
*
|
|
319
|
+
* @param options - Card configuration options
|
|
320
|
+
* @returns Interactive card object ready to send
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* ```typescript
|
|
324
|
+
* const card = buildInteractiveCard({
|
|
325
|
+
* title: "📋 今日热点速报",
|
|
326
|
+
* template: "blue",
|
|
327
|
+
* content: "🔹 **黄金突破5500美元** 创历史新高\\n🔹 **英国首相访华** 八年来首次",
|
|
328
|
+
* buttons: [
|
|
329
|
+
* { text: "查看完整热搜", type: "primary", url: "https://s.weibo.com/top/summary" },
|
|
330
|
+
* ]
|
|
331
|
+
* });
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
export function buildInteractiveCard(options: {
|
|
335
|
+
title?: string;
|
|
336
|
+
template?: CardTemplate;
|
|
337
|
+
content: string;
|
|
338
|
+
buttons?: CardButton[];
|
|
339
|
+
streaming_mode?: boolean;
|
|
340
|
+
}): Record<string, unknown> {
|
|
341
|
+
const { title, template = "default", content, buttons, streaming_mode } = options;
|
|
342
|
+
|
|
343
|
+
const elements: any[] = [];
|
|
344
|
+
|
|
345
|
+
// Add title header if provided
|
|
346
|
+
if (title) {
|
|
347
|
+
elements.push({
|
|
348
|
+
tag: "div",
|
|
349
|
+
text: {
|
|
350
|
+
tag: "plain_text",
|
|
351
|
+
content: title,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
elements.push({ tag: "hr" });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Add main content
|
|
358
|
+
elements.push({
|
|
359
|
+
tag: "markdown",
|
|
360
|
+
content: content,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Add buttons if provided
|
|
364
|
+
if (buttons && buttons.length > 0) {
|
|
365
|
+
elements.push({ tag: "hr" });
|
|
366
|
+
|
|
367
|
+
const actions = buttons.map((btn) => ({
|
|
368
|
+
tag: "button",
|
|
369
|
+
text: {
|
|
370
|
+
tag: "plain_text",
|
|
371
|
+
content: btn.text,
|
|
372
|
+
},
|
|
373
|
+
type: btn.type ?? "default",
|
|
374
|
+
url: btn.url,
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
elements.push({
|
|
378
|
+
tag: "action",
|
|
379
|
+
actions,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const card: Record<string, unknown> = {
|
|
384
|
+
config: {
|
|
385
|
+
wide_screen_mode: true,
|
|
386
|
+
},
|
|
387
|
+
header: title ? {
|
|
388
|
+
title: {
|
|
389
|
+
tag: "plain_text",
|
|
390
|
+
content: title,
|
|
391
|
+
},
|
|
392
|
+
template: template === "default" ? undefined : template,
|
|
393
|
+
} : undefined,
|
|
394
|
+
elements,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Add streaming_mode if provided
|
|
398
|
+
if (streaming_mode !== undefined) {
|
|
399
|
+
card.streaming_mode = streaming_mode;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Remove undefined header if no title
|
|
403
|
+
if (!title) {
|
|
404
|
+
delete card.header;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return card;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Create a simple text card for streaming updates.
|
|
412
|
+
* This is a lightweight card format optimized for real-time updates.
|
|
413
|
+
*
|
|
414
|
+
* @param text - The markdown text content
|
|
415
|
+
* @param streaming_mode - Whether to enable streaming mode
|
|
416
|
+
* @returns Simple text card object
|
|
417
|
+
*/
|
|
418
|
+
export function createSimpleTextCard(text: string, streaming_mode: boolean = false): Record<string, unknown> {
|
|
419
|
+
return {
|
|
420
|
+
config: {
|
|
421
|
+
wide_screen_mode: true,
|
|
422
|
+
},
|
|
423
|
+
elements: [
|
|
424
|
+
{
|
|
425
|
+
tag: "markdown",
|
|
426
|
+
content: text,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
streaming_mode,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Send a message as a markdown card (interactive message).
|
|
435
|
+
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
|
|
436
|
+
*/
|
|
437
|
+
export async function sendMarkdownCardFeishu(params: {
|
|
438
|
+
cfg: ClawdbotConfig;
|
|
439
|
+
to: string;
|
|
440
|
+
text: string;
|
|
441
|
+
replyToMessageId?: string;
|
|
442
|
+
/** Mention target users */
|
|
443
|
+
mentions?: MentionTarget[];
|
|
444
|
+
accountId?: string;
|
|
445
|
+
}): Promise<FeishuSendResult> {
|
|
446
|
+
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
|
447
|
+
// Build message content (with @mention support)
|
|
448
|
+
let cardText = text;
|
|
449
|
+
if (mentions && mentions.length > 0) {
|
|
450
|
+
cardText = buildMentionedCardContent(mentions, text);
|
|
451
|
+
}
|
|
452
|
+
const card = buildMarkdownCard(cardText);
|
|
453
|
+
return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Edit an existing text message.
|
|
458
|
+
* Note: Feishu only allows editing messages within 24 hours.
|
|
459
|
+
*/
|
|
460
|
+
export async function editMessageFeishu(params: {
|
|
461
|
+
cfg: ClawdbotConfig;
|
|
462
|
+
messageId: string;
|
|
463
|
+
text: string;
|
|
464
|
+
accountId?: string;
|
|
465
|
+
}): Promise<void> {
|
|
466
|
+
const { cfg, messageId, text, accountId } = params;
|
|
467
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
468
|
+
if (!account.configured) {
|
|
469
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const client = createFeishuClient(account);
|
|
473
|
+
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
|
474
|
+
cfg,
|
|
475
|
+
channel: "feishu",
|
|
476
|
+
});
|
|
477
|
+
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
|
|
478
|
+
|
|
479
|
+
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
|
|
480
|
+
|
|
481
|
+
const response = await client.im.message.update({
|
|
482
|
+
path: { message_id: messageId },
|
|
483
|
+
data: {
|
|
484
|
+
msg_type: msgType,
|
|
485
|
+
content,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
if (response.code !== 0) {
|
|
490
|
+
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createFeishuClient } from "./client.js";
|
|
3
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
4
|
+
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
|
5
|
+
import { createSimpleTextCard } from "./send.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for Feishu streaming updates
|
|
9
|
+
*/
|
|
10
|
+
const STREAM_CONFIG = {
|
|
11
|
+
// Rate limit: ~400ms between updates to avoid API limits
|
|
12
|
+
MIN_UPDATE_INTERVAL_MS: 400,
|
|
13
|
+
// Maximum number of pending updates to queue
|
|
14
|
+
MAX_PENDING_UPDATES: 10,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* FeishuStream manages streaming card updates with rate limiting.
|
|
19
|
+
* This provides a smooth streaming experience for AI responses.
|
|
20
|
+
*/
|
|
21
|
+
export class FeishuStream {
|
|
22
|
+
private messageId: string;
|
|
23
|
+
private cfg: ClawdbotConfig;
|
|
24
|
+
private accountId?: string;
|
|
25
|
+
private lastUpdateTime: number = 0;
|
|
26
|
+
private pendingContent: string = "";
|
|
27
|
+
private updateTimer: NodeJS.Timeout | null = null;
|
|
28
|
+
private isComplete: boolean = false;
|
|
29
|
+
private isDestroyed: boolean = false;
|
|
30
|
+
|
|
31
|
+
constructor(params: {
|
|
32
|
+
messageId: string;
|
|
33
|
+
cfg: ClawdbotConfig;
|
|
34
|
+
accountId?: string;
|
|
35
|
+
}) {
|
|
36
|
+
this.messageId = params.messageId;
|
|
37
|
+
this.cfg = params.cfg;
|
|
38
|
+
this.accountId = params.accountId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Update the streaming content.
|
|
43
|
+
* The update will be throttled according to STREAM_CONFIG.MIN_UPDATE_INTERVAL_MS.
|
|
44
|
+
*/
|
|
45
|
+
async update(content: string): Promise<void> {
|
|
46
|
+
if (this.isDestroyed || this.isComplete) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.pendingContent = content;
|
|
51
|
+
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const timeSinceLastUpdate = now - this.lastUpdateTime;
|
|
54
|
+
|
|
55
|
+
// If enough time has passed, update immediately
|
|
56
|
+
if (timeSinceLastUpdate >= STREAM_CONFIG.MIN_UPDATE_INTERVAL_MS) {
|
|
57
|
+
await this.flush();
|
|
58
|
+
} else if (!this.updateTimer) {
|
|
59
|
+
// Schedule an update for when the interval elapses
|
|
60
|
+
const delay = STREAM_CONFIG.MIN_UPDATE_INTERVAL_MS - timeSinceLastUpdate;
|
|
61
|
+
this.updateTimer = setTimeout(() => this.flush(), delay);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Finalize the stream by setting streaming_mode to false.
|
|
67
|
+
* This should be called when the AI response is complete.
|
|
68
|
+
*/
|
|
69
|
+
async finalize(finalContent: string): Promise<void> {
|
|
70
|
+
if (this.isDestroyed || this.isComplete) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Clear any pending timer
|
|
75
|
+
if (this.updateTimer) {
|
|
76
|
+
clearTimeout(this.updateTimer);
|
|
77
|
+
this.updateTimer = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.isComplete = true;
|
|
81
|
+
this.pendingContent = finalContent;
|
|
82
|
+
|
|
83
|
+
// Send final update with streaming_mode = false
|
|
84
|
+
await this.sendUpdate(finalContent, false);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Flush pending content to the card.
|
|
89
|
+
*/
|
|
90
|
+
private async flush(): Promise<void> {
|
|
91
|
+
if (this.updateTimer) {
|
|
92
|
+
clearTimeout(this.updateTimer);
|
|
93
|
+
this.updateTimer = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.lastUpdateTime = Date.now();
|
|
97
|
+
await this.sendUpdate(this.pendingContent, true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Send an update to the Feishu card.
|
|
102
|
+
*/
|
|
103
|
+
private async sendUpdate(content: string, streamingMode: boolean): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
const account = resolveFeishuAccount({ cfg: this.cfg, accountId: this.accountId });
|
|
106
|
+
if (!account.configured) {
|
|
107
|
+
console.error("FeishuStream: account not configured");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const client = createFeishuClient(account);
|
|
112
|
+
const card = createSimpleTextCard(content, streamingMode);
|
|
113
|
+
const cardContent = JSON.stringify(card);
|
|
114
|
+
|
|
115
|
+
await client.im.message.patch({
|
|
116
|
+
path: { message_id: this.messageId },
|
|
117
|
+
data: { content: cardContent },
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error("FeishuStream: failed to update card:", err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Destroy the stream and clean up resources.
|
|
126
|
+
*/
|
|
127
|
+
destroy(): void {
|
|
128
|
+
this.isDestroyed = true;
|
|
129
|
+
if (this.updateTimer) {
|
|
130
|
+
clearTimeout(this.updateTimer);
|
|
131
|
+
this.updateTimer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Type for partial reply callback
|
|
138
|
+
*/
|
|
139
|
+
export type OnPartialReply = (text: string) => void | Promise<void>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a streaming handler for Feishu replies.
|
|
143
|
+
* Returns the stream instance and an onPartialReply callback.
|
|
144
|
+
*/
|
|
145
|
+
export function createFeishuStreamingHandler(params: {
|
|
146
|
+
cfg: ClawdbotConfig;
|
|
147
|
+
messageId: string;
|
|
148
|
+
accountId?: string;
|
|
149
|
+
}): {
|
|
150
|
+
stream: FeishuStream;
|
|
151
|
+
onPartialReply: OnPartialReply;
|
|
152
|
+
} {
|
|
153
|
+
const stream = new FeishuStream(params);
|
|
154
|
+
|
|
155
|
+
const onPartialReply: OnPartialReply = async (text) => {
|
|
156
|
+
await stream.update(text);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return { stream, onPartialReply };
|
|
160
|
+
}
|
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
|
+
}
|