@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/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
+ }