@tobeyoureyes/feishu 1.0.0
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 +290 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +42 -0
- package/src/api.ts +1160 -0
- package/src/auth.ts +133 -0
- package/src/channel.ts +883 -0
- package/src/context.ts +292 -0
- package/src/dedupe.ts +85 -0
- package/src/dispatch.ts +185 -0
- package/src/history.ts +130 -0
- package/src/inbound.ts +83 -0
- package/src/message.ts +386 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +330 -0
- package/src/webhook.ts +549 -0
- package/src/websocket.ts +372 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu inbound message processing utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides helper functions for processing incoming messages.
|
|
5
|
+
* The main message handling is done in channel.ts using context.ts and dispatch.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ResolvedFeishuAccount, FeishuRenderMode } from "./types.js";
|
|
9
|
+
import type { FeishuInboundMessage } from "./webhook.js";
|
|
10
|
+
import type { HistoryEntry } from "./history.js";
|
|
11
|
+
import * as api from "./api.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Logger interface for inbound processing
|
|
15
|
+
*/
|
|
16
|
+
export interface InboundLogger {
|
|
17
|
+
info?(message: string): void;
|
|
18
|
+
debug?(message: string): void;
|
|
19
|
+
warn?(message: string): void;
|
|
20
|
+
error?(message: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reply sender function signature
|
|
25
|
+
*/
|
|
26
|
+
export type ReplySender = (params: {
|
|
27
|
+
text: string;
|
|
28
|
+
replyToId?: string;
|
|
29
|
+
renderMode?: FeishuRenderMode;
|
|
30
|
+
}) => Promise<{ ok: boolean; messageId?: string; error?: string }>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Media sender function signature
|
|
34
|
+
*/
|
|
35
|
+
export type MediaSender = (params: {
|
|
36
|
+
text?: string;
|
|
37
|
+
mediaUrl: string;
|
|
38
|
+
replyToId?: string;
|
|
39
|
+
}) => Promise<{ ok: boolean; messageId?: string; error?: string }>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetch reply context if message is a reply
|
|
43
|
+
*
|
|
44
|
+
* When a message has a parentId (is a reply), this function fetches
|
|
45
|
+
* the parent message content to provide context.
|
|
46
|
+
*/
|
|
47
|
+
export async function fetchReplyContext(
|
|
48
|
+
account: ResolvedFeishuAccount,
|
|
49
|
+
inbound: FeishuInboundMessage,
|
|
50
|
+
log?: InboundLogger,
|
|
51
|
+
): Promise<FeishuInboundMessage> {
|
|
52
|
+
if (!inbound.parentId) {
|
|
53
|
+
return inbound;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const parentResult = await api.getMessage(account, inbound.parentId);
|
|
58
|
+
if (parentResult.ok && parentResult.message) {
|
|
59
|
+
return {
|
|
60
|
+
...inbound,
|
|
61
|
+
replyToBody: parentResult.message.body,
|
|
62
|
+
replyToSenderId: parentResult.message.senderId,
|
|
63
|
+
replyToId: inbound.parentId,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
log?.warn?.(`failed to fetch parent message ${inbound.parentId}: ${String(err)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return inbound;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build history entry from inbound message
|
|
75
|
+
*/
|
|
76
|
+
export function buildHistoryEntry(inbound: FeishuInboundMessage): HistoryEntry {
|
|
77
|
+
return {
|
|
78
|
+
sender: inbound.senderOpenId ?? inbound.senderId,
|
|
79
|
+
body: inbound.displayText ?? inbound.text ?? "",
|
|
80
|
+
timestamp: inbound.createTime,
|
|
81
|
+
messageId: inbound.messageId,
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu message format conversion utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
FeishuPostContent,
|
|
7
|
+
FeishuPostElement,
|
|
8
|
+
FeishuInteractiveContent,
|
|
9
|
+
FeishuCardElement,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert plain text to Feishu text content
|
|
14
|
+
*/
|
|
15
|
+
export function textToFeishuContent(text: string): { text: string } {
|
|
16
|
+
return { text };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert markdown text to Feishu post (rich text) content
|
|
21
|
+
*/
|
|
22
|
+
export function markdownToFeishuPost(markdown: string, title?: string): FeishuPostContent {
|
|
23
|
+
const lines = markdown.split("\n");
|
|
24
|
+
const content: FeishuPostElement[][] = [];
|
|
25
|
+
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const trimmedLine = line.trim();
|
|
28
|
+
if (!trimmedLine) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const elements: FeishuPostElement[] = [];
|
|
33
|
+
let remaining = trimmedLine;
|
|
34
|
+
|
|
35
|
+
// Parse inline elements
|
|
36
|
+
while (remaining.length > 0) {
|
|
37
|
+
// Check for links: [text](url)
|
|
38
|
+
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
|
39
|
+
if (linkMatch) {
|
|
40
|
+
elements.push({
|
|
41
|
+
tag: "a",
|
|
42
|
+
text: linkMatch[1],
|
|
43
|
+
href: linkMatch[2],
|
|
44
|
+
});
|
|
45
|
+
remaining = remaining.slice(linkMatch[0].length);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check for bold: **text** or __text__
|
|
50
|
+
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*|^__([^_]+)__/);
|
|
51
|
+
if (boldMatch) {
|
|
52
|
+
elements.push({
|
|
53
|
+
tag: "text",
|
|
54
|
+
text: boldMatch[1] || boldMatch[2],
|
|
55
|
+
});
|
|
56
|
+
remaining = remaining.slice(boldMatch[0].length);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check for inline code: `code`
|
|
61
|
+
const codeMatch = remaining.match(/^`([^`]+)`/);
|
|
62
|
+
if (codeMatch) {
|
|
63
|
+
elements.push({
|
|
64
|
+
tag: "text",
|
|
65
|
+
text: codeMatch[1],
|
|
66
|
+
});
|
|
67
|
+
remaining = remaining.slice(codeMatch[0].length);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Find next special character or end of string
|
|
72
|
+
const nextSpecial = remaining.search(/\[|\*\*|__|`/);
|
|
73
|
+
if (nextSpecial === -1) {
|
|
74
|
+
// No more special characters, add remaining as text
|
|
75
|
+
elements.push({
|
|
76
|
+
tag: "text",
|
|
77
|
+
text: remaining,
|
|
78
|
+
});
|
|
79
|
+
break;
|
|
80
|
+
} else if (nextSpecial > 0) {
|
|
81
|
+
// Add text before special character
|
|
82
|
+
elements.push({
|
|
83
|
+
tag: "text",
|
|
84
|
+
text: remaining.slice(0, nextSpecial),
|
|
85
|
+
});
|
|
86
|
+
remaining = remaining.slice(nextSpecial);
|
|
87
|
+
} else {
|
|
88
|
+
// Special character not matched, treat as text
|
|
89
|
+
elements.push({
|
|
90
|
+
tag: "text",
|
|
91
|
+
text: remaining[0],
|
|
92
|
+
});
|
|
93
|
+
remaining = remaining.slice(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (elements.length > 0) {
|
|
98
|
+
content.push(elements);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
zh_cn: {
|
|
104
|
+
title,
|
|
105
|
+
content,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create a simple card with title and content
|
|
112
|
+
*/
|
|
113
|
+
export function createSimpleCard(
|
|
114
|
+
title: string,
|
|
115
|
+
content: string,
|
|
116
|
+
color: "blue" | "wathet" | "turquoise" | "green" | "yellow" | "orange" | "red" | "carmine" | "violet" | "purple" | "indigo" | "grey" = "blue",
|
|
117
|
+
): FeishuInteractiveContent {
|
|
118
|
+
return {
|
|
119
|
+
config: {
|
|
120
|
+
wide_screen_mode: true,
|
|
121
|
+
},
|
|
122
|
+
header: {
|
|
123
|
+
title: {
|
|
124
|
+
tag: "plain_text",
|
|
125
|
+
content: title,
|
|
126
|
+
},
|
|
127
|
+
template: color,
|
|
128
|
+
},
|
|
129
|
+
elements: [
|
|
130
|
+
{
|
|
131
|
+
tag: "markdown",
|
|
132
|
+
content,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a card with multiple sections
|
|
140
|
+
*/
|
|
141
|
+
export function createSectionCard(
|
|
142
|
+
title: string,
|
|
143
|
+
sections: Array<{ title?: string; content: string }>,
|
|
144
|
+
color: "blue" | "wathet" | "turquoise" | "green" | "yellow" | "orange" | "red" | "carmine" | "violet" | "purple" | "indigo" | "grey" = "blue",
|
|
145
|
+
): FeishuInteractiveContent {
|
|
146
|
+
const elements: FeishuCardElement[] = [];
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < sections.length; i++) {
|
|
149
|
+
const section = sections[i];
|
|
150
|
+
|
|
151
|
+
if (section.title) {
|
|
152
|
+
elements.push({
|
|
153
|
+
tag: "div",
|
|
154
|
+
text: {
|
|
155
|
+
tag: "lark_md",
|
|
156
|
+
content: `**${section.title}**`,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
elements.push({
|
|
162
|
+
tag: "markdown",
|
|
163
|
+
content: section.content,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Add divider between sections
|
|
167
|
+
if (i < sections.length - 1) {
|
|
168
|
+
elements.push({ tag: "hr" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
config: {
|
|
174
|
+
wide_screen_mode: true,
|
|
175
|
+
},
|
|
176
|
+
header: {
|
|
177
|
+
title: {
|
|
178
|
+
tag: "plain_text",
|
|
179
|
+
content: title,
|
|
180
|
+
},
|
|
181
|
+
template: color,
|
|
182
|
+
},
|
|
183
|
+
elements,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create a card with action buttons
|
|
189
|
+
*/
|
|
190
|
+
export function createActionCard(
|
|
191
|
+
title: string,
|
|
192
|
+
content: string,
|
|
193
|
+
actions: Array<{ text: string; url?: string; value?: unknown; type?: "primary" | "default" | "danger" }>,
|
|
194
|
+
color: "blue" | "wathet" | "turquoise" | "green" | "yellow" | "orange" | "red" | "carmine" | "violet" | "purple" | "indigo" | "grey" = "blue",
|
|
195
|
+
): FeishuInteractiveContent {
|
|
196
|
+
return {
|
|
197
|
+
config: {
|
|
198
|
+
wide_screen_mode: true,
|
|
199
|
+
},
|
|
200
|
+
header: {
|
|
201
|
+
title: {
|
|
202
|
+
tag: "plain_text",
|
|
203
|
+
content: title,
|
|
204
|
+
},
|
|
205
|
+
template: color,
|
|
206
|
+
},
|
|
207
|
+
elements: [
|
|
208
|
+
{
|
|
209
|
+
tag: "markdown",
|
|
210
|
+
content,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
tag: "action",
|
|
214
|
+
actions: actions.map((action) => ({
|
|
215
|
+
tag: "button",
|
|
216
|
+
text: {
|
|
217
|
+
tag: "plain_text",
|
|
218
|
+
content: action.text,
|
|
219
|
+
},
|
|
220
|
+
url: action.url,
|
|
221
|
+
type: action.type ?? "default",
|
|
222
|
+
value: action.value,
|
|
223
|
+
})),
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Convert Feishu message content to plain text
|
|
231
|
+
*/
|
|
232
|
+
export function feishuContentToText(content: string, msgType: string): string {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(content);
|
|
235
|
+
|
|
236
|
+
switch (msgType) {
|
|
237
|
+
case "text":
|
|
238
|
+
return parsed.text ?? "";
|
|
239
|
+
|
|
240
|
+
case "post":
|
|
241
|
+
return extractTextFromPost(parsed);
|
|
242
|
+
|
|
243
|
+
case "interactive":
|
|
244
|
+
return extractTextFromCard(parsed);
|
|
245
|
+
|
|
246
|
+
default:
|
|
247
|
+
return content;
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
return content;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Extract plain text from post content
|
|
256
|
+
*/
|
|
257
|
+
function extractTextFromPost(post: FeishuPostContent): string {
|
|
258
|
+
const texts: string[] = [];
|
|
259
|
+
const postBody = post.zh_cn ?? post.en_us;
|
|
260
|
+
|
|
261
|
+
if (postBody?.title) {
|
|
262
|
+
texts.push(postBody.title);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (postBody?.content) {
|
|
266
|
+
for (const line of postBody.content) {
|
|
267
|
+
const lineTexts: string[] = [];
|
|
268
|
+
for (const element of line) {
|
|
269
|
+
if (element.tag === "text") {
|
|
270
|
+
lineTexts.push(element.text);
|
|
271
|
+
} else if (element.tag === "a") {
|
|
272
|
+
lineTexts.push(element.text);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (lineTexts.length > 0) {
|
|
276
|
+
texts.push(lineTexts.join(""));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return texts.join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Extract plain text from card content
|
|
286
|
+
*/
|
|
287
|
+
function extractTextFromCard(card: FeishuInteractiveContent): string {
|
|
288
|
+
const texts: string[] = [];
|
|
289
|
+
|
|
290
|
+
if (card.header?.title?.content) {
|
|
291
|
+
texts.push(card.header.title.content);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (card.elements) {
|
|
295
|
+
for (const element of card.elements) {
|
|
296
|
+
if (element.tag === "markdown" && "content" in element) {
|
|
297
|
+
texts.push(element.content);
|
|
298
|
+
} else if (element.tag === "div" && "text" in element && element.text?.content) {
|
|
299
|
+
texts.push(element.text.content);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return texts.join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Escape special characters for Feishu markdown
|
|
309
|
+
*/
|
|
310
|
+
export function escapeFeishuMarkdown(text: string): string {
|
|
311
|
+
// Feishu markdown uses similar escaping to standard markdown
|
|
312
|
+
return text
|
|
313
|
+
.replace(/\\/g, "\\\\")
|
|
314
|
+
.replace(/\*/g, "\\*")
|
|
315
|
+
.replace(/_/g, "\\_")
|
|
316
|
+
.replace(/\[/g, "\\[")
|
|
317
|
+
.replace(/\]/g, "\\]")
|
|
318
|
+
.replace(/\(/g, "\\(")
|
|
319
|
+
.replace(/\)/g, "\\)")
|
|
320
|
+
.replace(/`/g, "\\`");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Format mentions for Feishu
|
|
325
|
+
*/
|
|
326
|
+
export function formatMention(userId: string, userName?: string): string {
|
|
327
|
+
// Feishu uses <at user_id="xxx">name</at> format in post messages
|
|
328
|
+
// For card messages, use @user_id format
|
|
329
|
+
return `<at user_id="${userId}">${userName ?? userId}</at>`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create a text content with @ mention
|
|
334
|
+
* Use open_id for the user to mention
|
|
335
|
+
*/
|
|
336
|
+
export function createTextWithMention(text: string, mentions: Array<{ openId: string; name: string }>): string {
|
|
337
|
+
// For text messages, use <at user_id="open_id">name</at> format
|
|
338
|
+
let result = text;
|
|
339
|
+
for (const mention of mentions) {
|
|
340
|
+
// Replace @name with the proper mention format
|
|
341
|
+
result = result.replace(
|
|
342
|
+
new RegExp(`@${mention.name}`, "g"),
|
|
343
|
+
`<at user_id="${mention.openId}">${mention.name}</at>`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Replace mention placeholders in text with actual names
|
|
351
|
+
* @param text - Text with placeholders like @_user_1
|
|
352
|
+
* @param mentions - Array of mentions with key and name
|
|
353
|
+
* @returns Text with placeholders replaced by @name
|
|
354
|
+
*/
|
|
355
|
+
export function replaceMentionPlaceholders(
|
|
356
|
+
text: string,
|
|
357
|
+
mentions: Array<{ key: string; name: string }> | undefined,
|
|
358
|
+
): string {
|
|
359
|
+
if (!text || !mentions || mentions.length === 0) {
|
|
360
|
+
return text;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let result = text;
|
|
364
|
+
for (const mention of mentions) {
|
|
365
|
+
result = result.replace(new RegExp(mention.key, "g"), `@${mention.name}`);
|
|
366
|
+
}
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Strip all mentions from text (for processing)
|
|
372
|
+
*/
|
|
373
|
+
export function stripMentions(
|
|
374
|
+
text: string,
|
|
375
|
+
mentions: Array<{ key: string }> | undefined,
|
|
376
|
+
): string {
|
|
377
|
+
if (!text || !mentions || mentions.length === 0) {
|
|
378
|
+
return text;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let result = text;
|
|
382
|
+
for (const mention of mentions) {
|
|
383
|
+
result = result.replace(new RegExp(mention.key, "g"), "");
|
|
384
|
+
}
|
|
385
|
+
return result.replace(/\s+/g, " ").trim();
|
|
386
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setFeishuRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getFeishuRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("Feishu runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|