openbird 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.

Potentially problematic release.


This version of openbird might be problematic. Click here for more details.

@@ -0,0 +1,592 @@
1
+ /**
2
+ * RichText Builder for Feishu messages
3
+ * Converts Markdown-like syntax to Feishu RichText protobuf structure
4
+ */
5
+
6
+ import { create, toBinary } from '@bufbuild/protobuf';
7
+ import { TextPropertySchema } from '../proto/proto_pb.js';
8
+ import { pushVarintLength } from '../utils/varint.js';
9
+
10
+ // RichText Element Tags
11
+ const Tags = {
12
+ TEXT: 1,
13
+ P: 3,
14
+ A: 6,
15
+ DOCS: 22,
16
+ UL: 26,
17
+ OL: 27,
18
+ LI: 28,
19
+ QUOTE: 29,
20
+ CODE_BLOCK: 31,
21
+ };
22
+
23
+ // Style keys for text formatting
24
+ const Styles = {
25
+ ITALIC: { fontStyle: 'italic' },
26
+ BOLD: { fontWeight: 'bold' },
27
+ STRIKETHROUGH: { '-lark-textDecoration': 'lineThrough' },
28
+ UNDERLINE: { '-lark-textDecoration': 'underline' },
29
+ };
30
+
31
+ // Simple ID generator
32
+ let idCounter = 0;
33
+ function nextId() {
34
+ return String(++idCounter);
35
+ }
36
+ function resetIdCounter() {
37
+ idCounter = 0;
38
+ }
39
+
40
+ /**
41
+ * Manually build RichTextElement bytes
42
+ * Format: tag(varint) + property(bytes, can be empty 1a00) + childIds(repeated string)
43
+ */
44
+ function buildElementBytes(tag, property = null, childIds = []) {
45
+ const parts = [];
46
+
47
+ // Field 1: tag (varint)
48
+ parts.push(0x08); // field 1, wire type 0
49
+ parts.push(tag);
50
+
51
+ // Field 3: property (bytes) - MUST include even if empty
52
+ parts.push(0x1a); // field 3, wire type 2
53
+ if (property && property.length > 0) {
54
+ pushVarintLength(parts, property.length);
55
+ parts.push(...property);
56
+ } else {
57
+ parts.push(0x00); // empty bytes
58
+ }
59
+
60
+ // Field 4: childIds (repeated string)
61
+ for (const childId of childIds) {
62
+ parts.push(0x22); // field 4, wire type 2
63
+ const idBytes = new TextEncoder().encode(childId);
64
+ pushVarintLength(parts, idBytes.length);
65
+ parts.push(...idBytes);
66
+ }
67
+
68
+ return new Uint8Array(parts);
69
+ }
70
+
71
+ /**
72
+ * Build dictionary entry bytes
73
+ */
74
+ function buildDictEntry(key, elementBytes) {
75
+ const keyBytes = new TextEncoder().encode(key);
76
+ const entryParts = [];
77
+
78
+ // Field 1: key (string)
79
+ entryParts.push(0x0a);
80
+ pushVarintLength(entryParts, keyBytes.length);
81
+ entryParts.push(...keyBytes);
82
+
83
+ // Field 2: value (element bytes)
84
+ entryParts.push(0x12);
85
+ pushVarintLength(entryParts, elementBytes.length);
86
+ entryParts.push(...elementBytes);
87
+
88
+ return new Uint8Array(entryParts);
89
+ }
90
+
91
+ /**
92
+ * Build complete dictionary bytes from entries
93
+ */
94
+ function buildDictionaryBytes(entries) {
95
+ const parts = [];
96
+ for (const [key, elementBytes] of Object.entries(entries)) {
97
+ const entryBytes = buildDictEntry(key, elementBytes);
98
+ // Map entry wrapper
99
+ parts.push(0x0a); // field 1 (map), wire type 2
100
+ pushVarintLength(parts, entryBytes.length);
101
+ parts.push(...entryBytes);
102
+ }
103
+ return new Uint8Array(parts);
104
+ }
105
+
106
+ /**
107
+ * Build TEXT element with property
108
+ */
109
+ function buildTextElement(text, styles = {}) {
110
+ const textProperty = create(TextPropertySchema, { content: text });
111
+ const propertyBytes = toBinary(TextPropertySchema, textProperty);
112
+
113
+ // If has styles, need to add style field
114
+ if (Object.keys(styles).length > 0) {
115
+ return buildElementBytesWithStyle(Tags.TEXT, propertyBytes, [], styles);
116
+ }
117
+
118
+ return buildElementBytes(Tags.TEXT, propertyBytes, []);
119
+ }
120
+
121
+ /**
122
+ * Build element with style
123
+ */
124
+ function buildElementBytesWithStyle(tag, property, childIds, styles) {
125
+ const parts = [];
126
+
127
+ // Field 1: tag
128
+ parts.push(0x08);
129
+ parts.push(tag);
130
+
131
+ // Field 2: style (map<string, string>)
132
+ for (const [key, value] of Object.entries(styles)) {
133
+ const keyBytes = new TextEncoder().encode(key);
134
+ const valueBytes = new TextEncoder().encode(value);
135
+
136
+ // Style entry
137
+ const styleEntry = [];
138
+ styleEntry.push(0x0a); // key field
139
+ pushVarintLength(styleEntry, keyBytes.length);
140
+ styleEntry.push(...keyBytes);
141
+ styleEntry.push(0x12); // value field
142
+ pushVarintLength(styleEntry, valueBytes.length);
143
+ styleEntry.push(...valueBytes);
144
+
145
+ parts.push(0x12); // field 2, wire type 2
146
+ pushVarintLength(parts, styleEntry.length);
147
+ parts.push(...styleEntry);
148
+ }
149
+
150
+ // Field 3: property
151
+ parts.push(0x1a);
152
+ if (property && property.length > 0) {
153
+ pushVarintLength(parts, property.length);
154
+ parts.push(...property);
155
+ } else {
156
+ parts.push(0x00);
157
+ }
158
+
159
+ // Field 4: childIds
160
+ for (const childId of childIds) {
161
+ parts.push(0x22);
162
+ const idBytes = new TextEncoder().encode(childId);
163
+ pushVarintLength(parts, idBytes.length);
164
+ parts.push(...idBytes);
165
+ }
166
+
167
+ return new Uint8Array(parts);
168
+ }
169
+
170
+ /**
171
+ * Encode link property
172
+ */
173
+ function encodeLinkProperty(url, text) {
174
+ const urlBytes = new TextEncoder().encode(url);
175
+ const textBytes = new TextEncoder().encode(text);
176
+ const buffer = [];
177
+ buffer.push(0x0a);
178
+ pushVarintLength(buffer, urlBytes.length);
179
+ buffer.push(...urlBytes);
180
+ buffer.push(0x12);
181
+ pushVarintLength(buffer, textBytes.length);
182
+ buffer.push(...textBytes);
183
+ buffer.push(0x48, 0x01); // field 9 = 1
184
+ buffer.push(0x50, 0x03); // field 10 = 3
185
+ return new Uint8Array(buffer);
186
+ }
187
+
188
+ /**
189
+ * Encode UL property
190
+ */
191
+ function encodeULProperty() {
192
+ return new Uint8Array([0x08, 0x00]);
193
+ }
194
+
195
+ /**
196
+ * Encode OL property with start index
197
+ * @param {number} startIndex - The starting number for the ordered list (default: 1)
198
+ */
199
+ function encodeOLProperty(startIndex = 1) {
200
+ // Ensure startIndex is at least 1
201
+ const index = Math.max(1, startIndex);
202
+
203
+ // For numbers <= 127, single byte encoding works
204
+ if (index <= 127) {
205
+ return new Uint8Array([0x08, 0x00, 0x10, index]);
206
+ }
207
+
208
+ // For larger numbers, use varint encoding for field 2
209
+ const bytes = [0x08, 0x00, 0x10];
210
+ let value = index;
211
+ while (value > 127) {
212
+ bytes.push((value & 0x7f) | 0x80);
213
+ value >>= 7;
214
+ }
215
+ bytes.push(value);
216
+ return new Uint8Array(bytes);
217
+ }
218
+
219
+ /**
220
+ * Unwrap inline Markdown style markers when they wrap the whole text
221
+ * Supports nested wrappers like **__text__**
222
+ */
223
+ function unwrapInlineStyles(text) {
224
+ let content = text;
225
+ let style = {};
226
+ let changed = true;
227
+
228
+ while (changed && content) {
229
+ changed = false;
230
+ let match;
231
+
232
+ match = content.match(/^\*\*(.+)\*\*$/);
233
+ if (match) {
234
+ style = { ...style, ...Styles.BOLD };
235
+ content = match[1];
236
+ changed = true;
237
+ continue;
238
+ }
239
+
240
+ match = content.match(/^__(.+)__$/);
241
+ if (match) {
242
+ style = { ...style, ...Styles.UNDERLINE };
243
+ content = match[1];
244
+ changed = true;
245
+ continue;
246
+ }
247
+
248
+ match = content.match(/^~~(.+)~~$/);
249
+ if (match) {
250
+ style = { ...style, ...Styles.STRIKETHROUGH };
251
+ content = match[1];
252
+ changed = true;
253
+ continue;
254
+ }
255
+
256
+ match = content.match(/^\*(.+)\*$/) || content.match(/^_(.+)_$/);
257
+ if (match) {
258
+ style = { ...style, ...Styles.ITALIC };
259
+ content = match[1];
260
+ changed = true;
261
+ }
262
+ }
263
+
264
+ return { content, style };
265
+ }
266
+
267
+ /**
268
+ * Parse inline formatting
269
+ */
270
+ function parseInlineFormatting(text) {
271
+ const elements = [];
272
+ const elementBytes = {};
273
+ let innerText = '';
274
+
275
+ const combinedRegex = /(\*\*\[([^\]]+)\]\(([^)]+)\)\*\*)|(\[\*\*([^\]]+)\*\*\]\(([^)]+)\))|(\*\*(.+?)\*\*)|(~~(.+?)~~)|(__(.+?)__)|(\*(.+?)\*)|(_([^_]+)_)|(\[([^\]]+)\]\(([^)]+)\))/g;
276
+
277
+ let match;
278
+ const matches = [];
279
+
280
+ while ((match = combinedRegex.exec(text)) !== null) {
281
+ let type = 'unknown';
282
+ let content = '';
283
+ let url = null;
284
+ let style = {};
285
+
286
+ if (match[1]) {
287
+ type = 'link';
288
+ content = match[2];
289
+ url = match[3];
290
+ style = { ...Styles.BOLD };
291
+ } else if (match[4]) {
292
+ type = 'link';
293
+ content = match[5];
294
+ url = match[6];
295
+ style = { ...Styles.BOLD };
296
+ } else if (match[7]) {
297
+ type = 'bold';
298
+ content = match[8];
299
+ } else if (match[9]) {
300
+ type = 'strikethrough';
301
+ content = match[10];
302
+ } else if (match[11]) {
303
+ type = 'underline';
304
+ content = match[12];
305
+ } else if (match[13]) {
306
+ type = 'italic';
307
+ content = match[14];
308
+ } else if (match[15]) {
309
+ type = 'italic';
310
+ content = match[16];
311
+ } else if (match[17]) {
312
+ type = 'link';
313
+ content = match[18];
314
+ url = match[19];
315
+ }
316
+
317
+ matches.push({
318
+ index: match.index,
319
+ length: match[0].length,
320
+ type,
321
+ content,
322
+ url,
323
+ style,
324
+ });
325
+ }
326
+
327
+ matches.sort((a, b) => a.index - b.index);
328
+
329
+ let currentIndex = 0;
330
+
331
+ for (const m of matches) {
332
+ if (m.index > currentIndex) {
333
+ const plainText = text.substring(currentIndex, m.index);
334
+ if (plainText) {
335
+ const id = nextId();
336
+ elements.push(id);
337
+ elementBytes[id] = buildTextElement(plainText);
338
+ innerText += plainText;
339
+ }
340
+ }
341
+
342
+ const id = nextId();
343
+ elements.push(id);
344
+
345
+ if (m.type === 'link') {
346
+ const normalized = unwrapInlineStyles(m.content);
347
+ const linkContent = normalized.content;
348
+ const linkStyle = { ...m.style, ...normalized.style };
349
+ const linkProp = encodeLinkProperty(m.url, linkContent);
350
+
351
+ if (Object.keys(linkStyle).length > 0) {
352
+ elementBytes[id] = buildElementBytesWithStyle(Tags.A, linkProp, [], linkStyle);
353
+ } else {
354
+ elementBytes[id] = buildElementBytes(Tags.A, linkProp, []);
355
+ }
356
+
357
+ innerText += linkContent;
358
+ } else {
359
+ let style = {};
360
+ switch (m.type) {
361
+ case 'bold': style = Styles.BOLD; break;
362
+ case 'italic': style = Styles.ITALIC; break;
363
+ case 'strikethrough': style = Styles.STRIKETHROUGH; break;
364
+ case 'underline': style = Styles.UNDERLINE; break;
365
+ }
366
+ elementBytes[id] = buildTextElement(m.content, style);
367
+ innerText += m.content;
368
+ }
369
+
370
+ currentIndex = m.index + m.length;
371
+ }
372
+
373
+ if (currentIndex < text.length) {
374
+ const plainText = text.substring(currentIndex);
375
+ if (plainText) {
376
+ const id = nextId();
377
+ elements.push(id);
378
+ elementBytes[id] = buildTextElement(plainText);
379
+ innerText += plainText;
380
+ }
381
+ }
382
+
383
+ if (elements.length === 0) {
384
+ const id = nextId();
385
+ elements.push(id);
386
+ elementBytes[id] = buildTextElement(text);
387
+ innerText = text;
388
+ }
389
+
390
+ return { elementIds: elements, elementBytes, innerText };
391
+ }
392
+
393
+ /**
394
+ * Parse a single line and return its block structure
395
+ * IDs are assigned depth-first (outside-in per block)
396
+ * @param {string} line - Single line of text
397
+ * @returns {Object} { blockChildId, elementBytes, innerText }
398
+ */
399
+ function parseSingleLine(line) {
400
+ const elementBytes = {};
401
+ let innerText = '';
402
+
403
+ // Check for quote
404
+ if (line.startsWith('> ')) {
405
+ const content = line.substring(2);
406
+
407
+ // Parse inline formatting within quote content
408
+ const inline = parseInlineFormatting(content);
409
+ Object.assign(elementBytes, inline.elementBytes);
410
+ innerText = inline.innerText;
411
+
412
+ // Structure: QUOTE → P → [inline elements]
413
+ const quoteId = nextId();
414
+ const pId = nextId();
415
+
416
+ elementBytes[pId] = buildElementBytes(Tags.P, null, inline.elementIds);
417
+ elementBytes[quoteId] = buildElementBytes(Tags.QUOTE, null, [pId]);
418
+
419
+ return { blockChildId: quoteId, elementBytes, innerText };
420
+ }
421
+
422
+ // Check for unordered list
423
+ if (/^[-*] /.test(line)) {
424
+ const content = line.substring(2);
425
+
426
+ // Parse inline formatting within list item
427
+ const inline = parseInlineFormatting(content);
428
+ Object.assign(elementBytes, inline.elementBytes);
429
+ innerText = inline.innerText;
430
+
431
+ // Structure: UL → LI → [inline elements]
432
+ const ulId = nextId();
433
+ const liId = nextId();
434
+
435
+ elementBytes[liId] = buildElementBytes(Tags.LI, null, inline.elementIds);
436
+ elementBytes[ulId] = buildElementBytes(Tags.UL, encodeULProperty(), [liId]);
437
+
438
+ return { blockChildId: ulId, elementBytes, innerText };
439
+ }
440
+
441
+ // Check for ordered list
442
+ const olMatch = line.match(/^(\d+)\. /);
443
+ if (olMatch) {
444
+ const startIndex = parseInt(olMatch[1], 10);
445
+ const content = line.replace(/^\d+\. /, '');
446
+
447
+ // Parse inline formatting within list item
448
+ const inline = parseInlineFormatting(content);
449
+ Object.assign(elementBytes, inline.elementBytes);
450
+ innerText = inline.innerText;
451
+
452
+ // Structure: OL → LI → [inline elements]
453
+ const olId = nextId();
454
+ const liId = nextId();
455
+
456
+ elementBytes[liId] = buildElementBytes(Tags.LI, null, inline.elementIds);
457
+ elementBytes[olId] = buildElementBytes(Tags.OL, encodeOLProperty(startIndex), [liId]);
458
+
459
+ return { blockChildId: olId, elementBytes, innerText };
460
+ }
461
+
462
+ // Plain text line - may contain inline formatting (bold, italic, etc.)
463
+ // Parse inline formatting and wrap in P element
464
+ const inline = parseInlineFormatting(line);
465
+ Object.assign(elementBytes, inline.elementBytes);
466
+ innerText = inline.innerText;
467
+
468
+ // Wrap inline elements in a P element
469
+ const pId = nextId();
470
+ elementBytes[pId] = buildElementBytes(Tags.P, null, inline.elementIds);
471
+
472
+ return { blockChildId: pId, elementBytes, innerText };
473
+ }
474
+
475
+ /**
476
+ * Build RichText structure from Markdown text
477
+ * Supports multi-line mixed format (plain text + quotes + lists)
478
+ */
479
+ export function buildRichText(markdown) {
480
+ resetIdCounter();
481
+ const elementBytes = {};
482
+ let innerText = '';
483
+
484
+ // Check for code block (special case - takes entire content)
485
+ const codeBlockMatch = markdown.match(/^```(\w*)\n?([\s\S]*?)```$/);
486
+ if (codeBlockMatch) {
487
+ const language = codeBlockMatch[1] || 'java';
488
+ const code = codeBlockMatch[2].trim();
489
+ innerText = `[代码块]`;
490
+
491
+ // Build from outside to inside, IDs from 1 to N
492
+ // Structure: DOCS(1) → P(2) → CODE_BLOCK(3)
493
+ const docsId = nextId(); // "1"
494
+ const pId = nextId(); // "2"
495
+ const codeId = nextId(); // "3"
496
+
497
+ // CODE_BLOCK with language property
498
+ const langBytes = new TextEncoder().encode(language);
499
+ const codeBlockProp = new Uint8Array([
500
+ 0x08, 0x1f, // field 1 = 31
501
+ 0x12, langBytes.length + 2, 0x12, langBytes.length, ...langBytes
502
+ ]);
503
+ elementBytes[codeId] = buildElementBytes(Tags.CODE_BLOCK, codeBlockProp, []);
504
+
505
+ // P → CODE_BLOCK
506
+ elementBytes[pId] = buildElementBytes(Tags.P, null, [codeId]);
507
+
508
+ // DOCS → P
509
+ elementBytes[docsId] = buildElementBytes(Tags.DOCS, null, [pId]);
510
+
511
+ return {
512
+ elementIds: [docsId],
513
+ innerText,
514
+ dictionaryBytes: buildDictionaryBytes(elementBytes),
515
+ isBlockLevel: true,
516
+ };
517
+ }
518
+
519
+ // Split into lines and filter empty lines
520
+ const lines = markdown.split('\n').filter(line => line.trim());
521
+
522
+ // Check if this is multi-line or has block-level elements
523
+ const hasMultipleLines = lines.length > 1;
524
+ const hasBlockElement = lines.some(line =>
525
+ line.startsWith('> ') ||
526
+ /^[-*] /.test(line) ||
527
+ /^\d+\. /.test(line)
528
+ );
529
+
530
+ // If single line without block elements, use simple inline formatting
531
+ if (!hasMultipleLines && !hasBlockElement) {
532
+ const inline = parseInlineFormatting(markdown);
533
+ Object.assign(elementBytes, inline.elementBytes);
534
+ innerText = inline.innerText;
535
+
536
+ return {
537
+ elementIds: inline.elementIds,
538
+ innerText,
539
+ dictionaryBytes: buildDictionaryBytes(elementBytes),
540
+ isBlockLevel: false,
541
+ };
542
+ }
543
+
544
+ // Multi-line or has block elements: build DOCS structure
545
+ // IDs assigned depth-first: DOCS first, then each block fully (outside-in)
546
+ const docsId = nextId(); // "1"
547
+ const blockChildIds = [];
548
+ const innerTexts = [];
549
+
550
+ for (const line of lines) {
551
+ const parsed = parseSingleLine(line);
552
+ blockChildIds.push(parsed.blockChildId);
553
+ Object.assign(elementBytes, parsed.elementBytes);
554
+ innerTexts.push(parsed.innerText);
555
+ }
556
+
557
+ // Build DOCS with all block children
558
+ elementBytes[docsId] = buildElementBytes(Tags.DOCS, null, blockChildIds);
559
+ innerText = innerTexts.join('');
560
+
561
+ return {
562
+ elementIds: [docsId],
563
+ innerText,
564
+ dictionaryBytes: buildDictionaryBytes(elementBytes),
565
+ isBlockLevel: true,
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Check if text contains any Markdown formatting
571
+ */
572
+ export function hasMarkdownFormatting(text) {
573
+ // Multi-line text is always treated as rich text
574
+ if (text.includes('\n')) {
575
+ return true;
576
+ }
577
+ const patterns = [
578
+ /\*\*.+?\*\*/,
579
+ /\*.+?\*/,
580
+ /_.+?_/,
581
+ /~~.+?~~/,
582
+ /__.+?__/,
583
+ /\[.+?\]\(.+?\)/,
584
+ /^> /m,
585
+ /^[-*] /m,
586
+ /^\d+\. /m,
587
+ /^```/m,
588
+ ];
589
+ return patterns.some(p => p.test(text));
590
+ }
591
+
592
+ export { Tags, Styles };