@max1874/feishu 0.2.16 → 0.2.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.
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Feishu Document Renderer using marked.
3
+ *
4
+ * Converts Markdown AST to Feishu Block structure.
5
+ */
6
+
7
+ import { Lexer, type Token, type Tokens } from "marked";
8
+
9
+ // Code language mapping for Feishu
10
+ export const CODE_LANG_MAP: Record<string, number> = {
11
+ python: 49,
12
+ py: 49,
13
+ javascript: 30,
14
+ js: 30,
15
+ typescript: 63,
16
+ ts: 63,
17
+ go: 22,
18
+ golang: 22,
19
+ rust: 53,
20
+ java: 29,
21
+ kotlin: 32,
22
+ kt: 32,
23
+ c: 10,
24
+ cpp: 9,
25
+ "c++": 9,
26
+ sql: 56,
27
+ bash: 7,
28
+ shell: 7,
29
+ sh: 7,
30
+ json: 28,
31
+ yaml: 67,
32
+ yml: 67,
33
+ markdown: 39,
34
+ md: 39,
35
+ html: 24,
36
+ css: 12,
37
+ swift: 61,
38
+ ruby: 52,
39
+ php: 43,
40
+ };
41
+
42
+ /**
43
+ * Placeholder for table content.
44
+ *
45
+ * Tables in Feishu require special handling via nested blocks API.
46
+ */
47
+ export class TablePlaceholder {
48
+ rows: string[][];
49
+ rowSize: number;
50
+ colSize: number;
51
+
52
+ constructor(rows: string[][]) {
53
+ this.rows = rows;
54
+ this.rowSize = rows.length;
55
+ this.colSize = rows.length > 0 ? Math.max(...rows.map((r) => r.length)) : 0;
56
+ }
57
+
58
+ toString(): string {
59
+ return `TablePlaceholder(${this.rowSize}x${this.colSize})`;
60
+ }
61
+ }
62
+
63
+ /** Feishu text element */
64
+ export interface TextElement {
65
+ text_run: {
66
+ content: string;
67
+ text_element_style?: {
68
+ bold?: boolean;
69
+ italic?: boolean;
70
+ strikethrough?: boolean;
71
+ inline_code?: boolean;
72
+ link?: { url: string };
73
+ };
74
+ };
75
+ }
76
+
77
+ /** Feishu block structure */
78
+ export interface FeishuBlock {
79
+ block_id: string;
80
+ block_type: number;
81
+ children: string[];
82
+ text?: { elements: TextElement[] };
83
+ heading1?: { elements: TextElement[] };
84
+ heading2?: { elements: TextElement[] };
85
+ heading3?: { elements: TextElement[] };
86
+ heading4?: { elements: TextElement[] };
87
+ heading5?: { elements: TextElement[] };
88
+ heading6?: { elements: TextElement[] };
89
+ heading7?: { elements: TextElement[] };
90
+ heading8?: { elements: TextElement[] };
91
+ heading9?: { elements: TextElement[] };
92
+ bullet?: { elements: TextElement[] };
93
+ ordered?: { elements: TextElement[] };
94
+ code?: { elements: TextElement[]; style: { language: number } };
95
+ quote_container?: Record<string, never>;
96
+ divider?: Record<string, never>;
97
+ _nested_blocks?: FeishuBlock[];
98
+ }
99
+
100
+ /** Content item: either a block or a table placeholder */
101
+ export type ContentItem = FeishuBlock | TablePlaceholder;
102
+
103
+ /**
104
+ * Render Markdown to Feishu Block structure.
105
+ */
106
+ export class FeishuRenderer {
107
+ private blockId = 0;
108
+
109
+ private nextId(): string {
110
+ this.blockId++;
111
+ return `block_${this.blockId}`;
112
+ }
113
+
114
+ /**
115
+ * Render markdown tokens to Feishu blocks.
116
+ */
117
+ render(tokens: Token[]): ContentItem[] {
118
+ const result: ContentItem[] = [];
119
+ for (const token of tokens) {
120
+ const block = this.renderToken(token);
121
+ if (block !== null) {
122
+ if (Array.isArray(block)) {
123
+ result.push(...block);
124
+ } else {
125
+ result.push(block);
126
+ }
127
+ }
128
+ }
129
+ return result;
130
+ }
131
+
132
+ private renderToken(token: Token): ContentItem | ContentItem[] | null {
133
+ switch (token.type) {
134
+ case "paragraph":
135
+ return this.paragraph(token as Tokens.Paragraph);
136
+ case "heading":
137
+ return this.heading(token as Tokens.Heading);
138
+ case "code":
139
+ return this.blockCode(token as Tokens.Code);
140
+ case "blockquote":
141
+ return this.blockQuote(token as Tokens.Blockquote);
142
+ case "list":
143
+ return this.list(token as Tokens.List);
144
+ case "hr":
145
+ return this.thematicBreak();
146
+ case "table":
147
+ return this.table(token as Tokens.Table);
148
+ case "space":
149
+ return null;
150
+ default:
151
+ return null;
152
+ }
153
+ }
154
+
155
+ // === Block-level rendering ===
156
+
157
+ private paragraph(token: Tokens.Paragraph): FeishuBlock {
158
+ const elements = this.renderInlineTokens(token.tokens ?? []);
159
+ return {
160
+ block_id: this.nextId(),
161
+ block_type: 2,
162
+ text: { elements },
163
+ children: [],
164
+ };
165
+ }
166
+
167
+ private heading(token: Tokens.Heading): FeishuBlock {
168
+ const level = token.depth;
169
+ const elements = this.renderInlineTokens(token.tokens ?? []);
170
+ const key = `heading${level}` as keyof FeishuBlock;
171
+ return {
172
+ block_id: this.nextId(),
173
+ block_type: 2 + level, // heading1=3, heading2=4, etc.
174
+ [key]: { elements },
175
+ children: [],
176
+ };
177
+ }
178
+
179
+ private blockCode(token: Tokens.Code): FeishuBlock {
180
+ const lang = token.lang ?? "";
181
+ const langCode = CODE_LANG_MAP[lang.toLowerCase()] ?? 1;
182
+ return {
183
+ block_id: this.nextId(),
184
+ block_type: 14,
185
+ code: {
186
+ elements: [{ text_run: { content: token.text } }],
187
+ style: { language: langCode },
188
+ },
189
+ children: [],
190
+ };
191
+ }
192
+
193
+ private blockQuote(token: Tokens.Blockquote): FeishuBlock {
194
+ const containerId = this.nextId();
195
+ const nestedBlocks: FeishuBlock[] = [];
196
+
197
+ for (const child of token.tokens ?? []) {
198
+ const block = this.renderToken(child);
199
+ if (block !== null) {
200
+ if (Array.isArray(block)) {
201
+ for (const b of block) {
202
+ if (!(b instanceof TablePlaceholder)) {
203
+ nestedBlocks.push(b);
204
+ }
205
+ }
206
+ } else if (!(block instanceof TablePlaceholder)) {
207
+ nestedBlocks.push(block);
208
+ }
209
+ }
210
+ }
211
+
212
+ return {
213
+ block_id: containerId,
214
+ block_type: 34, // quote_container
215
+ quote_container: {},
216
+ children: nestedBlocks.map((b) => b.block_id),
217
+ _nested_blocks: nestedBlocks,
218
+ };
219
+ }
220
+
221
+ private list(token: Tokens.List): FeishuBlock[] {
222
+ const ordered = token.ordered;
223
+ const blockType = ordered ? 13 : 12;
224
+ const key = ordered ? "ordered" : "bullet";
225
+
226
+ const result: FeishuBlock[] = [];
227
+ for (const item of token.items) {
228
+ const elements = this.renderListItemContent(item);
229
+ result.push({
230
+ block_id: this.nextId(),
231
+ block_type: blockType,
232
+ [key]: { elements: elements.length > 0 ? elements : [{ text_run: { content: "" } }] },
233
+ children: [],
234
+ });
235
+ }
236
+ return result;
237
+ }
238
+
239
+ private renderListItemContent(item: Tokens.ListItem): TextElement[] {
240
+ const elements: TextElement[] = [];
241
+ for (const child of item.tokens ?? []) {
242
+ if (child.type === "text") {
243
+ const textToken = child as Tokens.Text;
244
+ if (textToken.tokens) {
245
+ elements.push(...this.renderInlineTokens(textToken.tokens));
246
+ } else {
247
+ elements.push({ text_run: { content: textToken.raw } });
248
+ }
249
+ } else if (child.type === "paragraph") {
250
+ const paraToken = child as Tokens.Paragraph;
251
+ elements.push(...this.renderInlineTokens(paraToken.tokens ?? []));
252
+ }
253
+ }
254
+ return elements;
255
+ }
256
+
257
+ private thematicBreak(): FeishuBlock {
258
+ return {
259
+ block_id: this.nextId(),
260
+ block_type: 22,
261
+ divider: {},
262
+ children: [],
263
+ };
264
+ }
265
+
266
+ // === Table rendering ===
267
+
268
+ private table(token: Tokens.Table): TablePlaceholder {
269
+ const rows: string[][] = [];
270
+
271
+ // Header row
272
+ const headerRow: string[] = [];
273
+ for (const cell of token.header) {
274
+ headerRow.push(this.extractTextContent(cell.tokens));
275
+ }
276
+ rows.push(headerRow);
277
+
278
+ // Body rows
279
+ for (const row of token.rows) {
280
+ const rowData: string[] = [];
281
+ for (const cell of row) {
282
+ rowData.push(this.extractTextContent(cell.tokens));
283
+ }
284
+ rows.push(rowData);
285
+ }
286
+
287
+ return new TablePlaceholder(rows);
288
+ }
289
+
290
+ private extractTextContent(tokens: Token[]): string {
291
+ const parts: string[] = [];
292
+ for (const token of tokens) {
293
+ if (token.type === "text") {
294
+ parts.push((token as Tokens.Text).text);
295
+ } else if (token.type === "codespan") {
296
+ parts.push((token as Tokens.Codespan).text);
297
+ } else if (token.type === "strong" || token.type === "em" || token.type === "del") {
298
+ const t = token as Tokens.Strong | Tokens.Em | Tokens.Del;
299
+ parts.push(this.extractTextContent(t.tokens ?? []));
300
+ } else if (token.type === "link") {
301
+ const linkToken = token as Tokens.Link;
302
+ parts.push(this.extractTextContent(linkToken.tokens ?? []));
303
+ }
304
+ }
305
+ return parts.join("");
306
+ }
307
+
308
+ // === Inline-level rendering ===
309
+
310
+ private renderInlineTokens(tokens: Token[]): TextElement[] {
311
+ const elements: TextElement[] = [];
312
+ for (const token of tokens) {
313
+ const result = this.renderInline(token);
314
+ if (result) {
315
+ if (Array.isArray(result)) {
316
+ elements.push(...result);
317
+ } else {
318
+ elements.push(result);
319
+ }
320
+ }
321
+ }
322
+ return elements.length > 0 ? elements : [{ text_run: { content: "" } }];
323
+ }
324
+
325
+ private renderInline(token: Token): TextElement | TextElement[] | null {
326
+ switch (token.type) {
327
+ case "text":
328
+ return { text_run: { content: (token as Tokens.Text).text } };
329
+
330
+ case "strong": {
331
+ const strongToken = token as Tokens.Strong;
332
+ const elements = this.renderInlineTokens(strongToken.tokens ?? []);
333
+ for (const el of elements) {
334
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
335
+ el.text_run.text_element_style.bold = true;
336
+ }
337
+ return elements;
338
+ }
339
+
340
+ case "em": {
341
+ const emToken = token as Tokens.Em;
342
+ const elements = this.renderInlineTokens(emToken.tokens ?? []);
343
+ for (const el of elements) {
344
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
345
+ el.text_run.text_element_style.italic = true;
346
+ }
347
+ return elements;
348
+ }
349
+
350
+ case "del": {
351
+ const delToken = token as Tokens.Del;
352
+ const elements = this.renderInlineTokens(delToken.tokens ?? []);
353
+ for (const el of elements) {
354
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
355
+ el.text_run.text_element_style.strikethrough = true;
356
+ }
357
+ return elements;
358
+ }
359
+
360
+ case "codespan":
361
+ return {
362
+ text_run: {
363
+ content: (token as Tokens.Codespan).text,
364
+ text_element_style: { inline_code: true },
365
+ },
366
+ };
367
+
368
+ case "link": {
369
+ const linkToken = token as Tokens.Link;
370
+ const elements = this.renderInlineTokens(linkToken.tokens ?? []);
371
+ const encodedUrl = encodeURIComponent(linkToken.href);
372
+ for (const el of elements) {
373
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
374
+ el.text_run.text_element_style.link = { url: encodedUrl };
375
+ }
376
+ return elements;
377
+ }
378
+
379
+ case "br":
380
+ return { text_run: { content: "\n" } };
381
+
382
+ case "html":
383
+ return { text_run: { content: (token as Tokens.HTML).raw } };
384
+
385
+ default:
386
+ return null;
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Convert Markdown to Feishu Block structure.
393
+ *
394
+ * @param mdContent Markdown content string
395
+ * @returns Object with blocks array and tables array
396
+ */
397
+ export function markdownToFeishuBlocks(mdContent: string): {
398
+ blocks: FeishuBlock[];
399
+ tables: TablePlaceholder[];
400
+ allItems: ContentItem[];
401
+ } {
402
+ const lexer = new Lexer();
403
+ const tokens = lexer.lex(mdContent);
404
+
405
+ const renderer = new FeishuRenderer();
406
+ const items = renderer.render(tokens);
407
+
408
+ const blocks: FeishuBlock[] = [];
409
+ const tables: TablePlaceholder[] = [];
410
+
411
+ function collectBlocks(item: ContentItem) {
412
+ if (item instanceof TablePlaceholder) {
413
+ tables.push(item);
414
+ } else {
415
+ blocks.push(item);
416
+ // Collect nested blocks (e.g., from blockquote)
417
+ if (item._nested_blocks) {
418
+ for (const nested of item._nested_blocks) {
419
+ blocks.push(nested);
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ for (const item of items) {
426
+ collectBlocks(item);
427
+ }
428
+
429
+ return { blocks, tables, allItems: items };
430
+ }
431
+
432
+ /**
433
+ * Prepare blocks for Feishu API.
434
+ * Removes helper fields before sending to API.
435
+ */
436
+ export function prepareBlocksForApi(blocks: FeishuBlock[]): any[] {
437
+ return blocks.map((block) => {
438
+ const { block_id, children, _nested_blocks, ...rest } = block;
439
+ return rest;
440
+ });
441
+ }
package/src/runtime.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
 
3
4
  let runtime: PluginRuntime | null = null;
4
5
 
@@ -12,3 +13,21 @@ export function getFeishuRuntime(): PluginRuntime {
12
13
  }
13
14
  return runtime;
14
15
  }
16
+
17
+ // --- Conversation context (tracked via AsyncLocalStorage for concurrency safety) ---
18
+
19
+ export interface FeishuConversationContext {
20
+ senderOpenId: string;
21
+ chatId: string;
22
+ chatType: "group" | "p2p";
23
+ }
24
+
25
+ const conversationStore = new AsyncLocalStorage<FeishuConversationContext>();
26
+
27
+ export function runWithConversationContext<T>(ctx: FeishuConversationContext, fn: () => T): T {
28
+ return conversationStore.run(ctx, fn);
29
+ }
30
+
31
+ export function getConversationContext(): FeishuConversationContext | undefined {
32
+ return conversationStore.getStore();
33
+ }