@sunafterrainwm/telegram-entities-builder 0.1.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/index.ts ADDED
@@ -0,0 +1,582 @@
1
+ import type * as TT from '@grammyjs/types';
2
+
3
+ type Telegram = TT.ApiMethods<never>;
4
+ type ApiParameters<T extends keyof Telegram> = Parameters<Telegram[T]>[0];
5
+
6
+ /**
7
+ * Represents a Telegram MessageEntity without the offset and length properties.
8
+ */
9
+ export type PartialEntity<E extends TT.MessageEntity = TT.MessageEntity> = Omit<E, 'offset' | 'length'>;
10
+ /**
11
+ * Represents any draft entity that can be used before calculating the final offset and length.
12
+ */
13
+ export type AnyDraftEntity =
14
+ | PartialEntity<TT.MessageEntity.CommonMessageEntity>
15
+ | PartialEntity<TT.MessageEntity.CustomEmojiMessageEntity>
16
+ | PartialEntity<TT.MessageEntity.PreMessageEntity>
17
+ | PartialEntity<TT.MessageEntity.TextLinkMessageEntity>
18
+ | PartialEntity<TT.MessageEntity.TextMentionMessageEntity>;
19
+
20
+ /**
21
+ * Represents a segment of text which may contain one or multiple entities.
22
+ */
23
+ export type TextSegment = TextSegment.SingleTextSegment | TextSegment.MultiTextSegment;
24
+
25
+ /**
26
+ * Namespace for TextSegment types.
27
+ */
28
+ // eslint-disable-next-line @typescript-eslint/no-namespace
29
+ export declare namespace TextSegment {
30
+ interface SingleTextSegment {
31
+ text: string;
32
+ entity?: AnyDraftEntity;
33
+ entities?: never;
34
+ }
35
+
36
+ interface MultiTextSegment {
37
+ text: string;
38
+ entity?: never;
39
+ entities: AnyDraftEntity[];
40
+ }
41
+ }
42
+
43
+ type PartialRequired<T, REQUIRED extends keyof T, OPTIONAL extends keyof T> = Required<Pick<T, REQUIRED>> &
44
+ Pick<T, OPTIONAL>;
45
+
46
+ /**
47
+ * Base interface for entity builders, providing fundamental operations for appending text and managing builder state.
48
+ */
49
+ export interface IEntityBuilderBase {
50
+ /**
51
+ * Appends plain text.
52
+ */
53
+ addText(text: string): this;
54
+ /**
55
+ * Appends text with a single entity.
56
+ */
57
+ addTextEntity(text: string, entity: AnyDraftEntity): this;
58
+ /**
59
+ * Appends text with multiple entities.
60
+ */
61
+ addTextEntities(text: string, entities: AnyDraftEntity[]): this;
62
+ /**
63
+ * Appends a list of text segments.
64
+ */
65
+ addTextSegmentList(segments: TextSegment[]): this;
66
+
67
+ /**
68
+ * Creates a deep clone of this builder.
69
+ */
70
+ clone(): IEntityBuilderBase;
71
+ /**
72
+ * Creates a child builder that forks from this instance.
73
+ */
74
+ fork(): IEntityBuilderBase;
75
+ /**
76
+ * Merges a forked builder back into its parent.
77
+ */
78
+ merge(wrapperEntities?: AnyDraftEntity[]): void;
79
+ }
80
+
81
+ /**
82
+ * Advanced entity builder interface supporting string manipulation, entity sorting, and payload generation.
83
+ */
84
+ export interface IEntityBuilder extends IEntityBuilderBase {
85
+ /**
86
+ * Trims or slices the string in-place, and automatically recalculates, filters, or truncates the affected entities.
87
+ */
88
+ sliceInplace(start?: number, end?: number): this;
89
+ /**
90
+ * Returns a new sliced instance.
91
+ */
92
+ slice(start?: number, end?: number): IEntityBuilder;
93
+ /**
94
+ * Removes leading whitespace in-place.
95
+ */
96
+ trimStart(): this;
97
+ /**
98
+ * Removes trailing whitespace in-place.
99
+ */
100
+ trimEnd(): this;
101
+ /**
102
+ * Removes leading and trailing whitespace in-place.
103
+ */
104
+ trim(): this;
105
+
106
+ /**
107
+ * Sorts the entities based on their offset and length.
108
+ */
109
+ sortEntities(): this;
110
+
111
+ /**
112
+ * Builds the payload for sending a text message.
113
+ */
114
+ buildTextPayload(): PartialRequired<ApiParameters<'sendMessage'>, 'text', 'entities' | 'link_preview_options'>;
115
+ /**
116
+ * Builds the payload for sending a document with a caption.
117
+ */
118
+ buildCaptionPayload(): PartialRequired<ApiParameters<'sendDocument'>, 'caption', 'caption_entities'>;
119
+ /**
120
+ * Builds the payload for inline query results.
121
+ */
122
+ buildInlinePayload(): PartialRequired<
123
+ TT.InputTextMessageContent,
124
+ 'message_text',
125
+ 'entities' | 'link_preview_options'
126
+ >;
127
+
128
+ clone(): IEntityBuilder;
129
+ fork(): IEntityBuilder;
130
+ }
131
+
132
+ /**
133
+ * Main implementation of IEntityBuilder that eagerly evaluates and stores entities.
134
+ */
135
+ export class EntityBuilder implements IEntityBuilder {
136
+ #text = '';
137
+ #entities: TT.MessageEntity[] = [];
138
+ #parent?: EntityBuilder;
139
+
140
+ public addText(text: string): this {
141
+ this.#text += text;
142
+ return this;
143
+ }
144
+
145
+ public addTextEntity(text: string, entity: AnyDraftEntity): this {
146
+ text = String(text);
147
+ if (text.length > 0) {
148
+ this.#entities.push({
149
+ ...entity,
150
+ offset: this.#text.length,
151
+ length: text.length,
152
+ });
153
+ this.#text += text;
154
+ }
155
+ return this;
156
+ }
157
+
158
+ public addTextEntities(text: string, entities: AnyDraftEntity[]): this {
159
+ for (const entity of entities) {
160
+ this.#entities.push({
161
+ ...entity,
162
+ offset: this.#text.length,
163
+ length: text.length,
164
+ } as TT.MessageEntity);
165
+ }
166
+ this.#text += text;
167
+ return this;
168
+ }
169
+
170
+ public addTextSegmentList(segments: TextSegment[]): this {
171
+ for (const segment of segments) {
172
+ if (segment.entity) {
173
+ this.addTextEntity(segment.text, segment.entity);
174
+ } else if (segment.entities) {
175
+ this.addTextEntities(segment.text, segment.entities);
176
+ } else {
177
+ this.#text += segment.text;
178
+ }
179
+ }
180
+ return this;
181
+ }
182
+
183
+ public buildTextPayload() {
184
+ return {
185
+ text: this.#text,
186
+ entities: this.#entities,
187
+ };
188
+ }
189
+
190
+ public buildCaptionPayload() {
191
+ return {
192
+ caption: this.#text,
193
+ caption_entities: this.#entities as TT.MessageEntity[],
194
+ };
195
+ }
196
+
197
+ public buildInlinePayload() {
198
+ return {
199
+ message_text: this.#text,
200
+ entities: this.#entities,
201
+ };
202
+ }
203
+
204
+ public sortEntities() {
205
+ this.#entities.sort((a, b) => a.offset - b.offset || a.length - b.length);
206
+ return this;
207
+ }
208
+
209
+ public sliceInplace(start?: number, end?: number): this {
210
+ if (!start && !end) {
211
+ return this;
212
+ }
213
+
214
+ const len = this.#text.length;
215
+
216
+ const s = start === undefined ? 0 : start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
217
+ const e = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
218
+
219
+ if (s >= e) {
220
+ this.#text = '';
221
+ this.#entities = [];
222
+ return this;
223
+ }
224
+
225
+ this.#text = this.#text.slice(s, e);
226
+ this.#entities = this.#entities.flatMap<TT.MessageEntity>((entity) => {
227
+ // 計算 Entity 區間與保留文字區間 [s, e) 的交集
228
+ const overlapStart = Math.max(s, entity.offset);
229
+ const overlapEnd = Math.min(e, entity.offset + entity.length);
230
+
231
+ // 若交集長度大於 0,代表此 Entity 仍有部分存活於新字串中
232
+ if (overlapStart < overlapEnd) {
233
+ return [
234
+ {
235
+ ...entity,
236
+ offset: overlapStart - s,
237
+ length: overlapEnd - overlapStart,
238
+ },
239
+ ];
240
+ }
241
+
242
+ // 剔除沒有交集的 Entity
243
+ return [];
244
+ });
245
+
246
+ return this;
247
+ }
248
+
249
+ public slice(start?: number, end?: number): EntityBuilder {
250
+ return this.clone().sliceInplace(start, end);
251
+ }
252
+
253
+ public trimStart(): this {
254
+ const trimmedLength = this.#text.trimStart().length;
255
+ const diff = this.#text.length - trimmedLength;
256
+ if (diff > 0) {
257
+ this.sliceInplace(diff);
258
+ }
259
+
260
+ return this;
261
+ }
262
+
263
+ public trimEnd(): this {
264
+ const trimmedLength = this.#text.trimEnd().length;
265
+ if (trimmedLength < this.#text.length) {
266
+ this.sliceInplace(0, trimmedLength);
267
+ }
268
+
269
+ return this;
270
+ }
271
+
272
+ public trim(): this {
273
+ const originalLength = this.#text.length;
274
+
275
+ // Calculate the number of leading whitespace characters
276
+ const startOffset = originalLength - this.#text.trimStart().length;
277
+ // Calculate the effective length of the string after removing trailing whitespace (i.e., end index)
278
+ const endOffset = this.#text.trimEnd().length;
279
+
280
+ if (startOffset > 0 || endOffset < originalLength) {
281
+ this.sliceInplace(startOffset, endOffset);
282
+ }
283
+
284
+ return this;
285
+ }
286
+
287
+ public clone(): EntityBuilder {
288
+ const instance = new EntityBuilder();
289
+ instance.#text = this.#text;
290
+ instance.#entities = structuredClone(this.#entities);
291
+ return instance;
292
+ }
293
+
294
+ public fork(): EntityBuilder {
295
+ const instance = new EntityBuilder();
296
+ instance.#parent = this;
297
+ return instance;
298
+ }
299
+
300
+ /**
301
+ * Merges a payload text and its entities into this builder, optionally wrapping them with additional entities.
302
+ */
303
+ public mergePayload(text: string, entities: TT.MessageEntity[], wrappers: AnyDraftEntity[] = []): void {
304
+ const startOffset = this.#text.length;
305
+ this.#text += text;
306
+
307
+ this.#entities.push(
308
+ // 1. Mount Wrapper (length covers the entire sub-block)
309
+ ...wrappers.map(
310
+ (w) =>
311
+ ({
312
+ ...w,
313
+ offset: startOffset,
314
+ length: text.length,
315
+ }) as TT.MessageEntity,
316
+ ),
317
+ // 2. Offset the entities of the child node itself
318
+ ...entities.map((e) => ({
319
+ ...e,
320
+ offset: e.offset + startOffset,
321
+ })),
322
+ );
323
+ }
324
+
325
+ public merge(wrapperEntities: AnyDraftEntity[] = []): void {
326
+ if (!this.#parent) {
327
+ throw new Error('Cannot merge: This builder is not a fork or has already been merged.');
328
+ }
329
+ this.#parent.mergePayload(this.#text, this.#entities, wrapperEntities);
330
+ this.#parent = undefined;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * A lazy implementation of IEntityBuilderBase that stores segments and flattens them into an EntityBuilder only when needed.
336
+ */
337
+ export class LazyEntityBuilder implements IEntityBuilderBase {
338
+ #segments: TextSegment[] = [];
339
+ #parent?: LazyEntityBuilder;
340
+
341
+ /**
342
+ * Flattens the lazy segments into a new EntityBuilder instance.
343
+ */
344
+ public flatten(): EntityBuilder {
345
+ const builder = new EntityBuilder();
346
+ builder.addTextSegmentList(this.#segments);
347
+ return builder;
348
+ }
349
+
350
+ public addText(text: string): this {
351
+ this.#segments.push({ text });
352
+ return this;
353
+ }
354
+
355
+ public addTextEntity(text: string, entity: AnyDraftEntity): this {
356
+ this.#segments.push({ text, entity });
357
+ return this;
358
+ }
359
+
360
+ public addTextEntities(text: string, entities: AnyDraftEntity[]): this {
361
+ this.#segments.push({ text, entities });
362
+ return this;
363
+ }
364
+
365
+ public addTextSegmentList(segments: TextSegment[]): this {
366
+ this.#segments.push(...segments);
367
+ return this;
368
+ }
369
+
370
+ public clone(): LazyEntityBuilder {
371
+ const instance = new LazyEntityBuilder();
372
+ instance.#segments = structuredClone(this.#segments);
373
+ return instance;
374
+ }
375
+
376
+ public fork(): LazyEntityBuilder {
377
+ const instance = new LazyEntityBuilder();
378
+ instance.#parent = this;
379
+ return instance;
380
+ }
381
+
382
+ public merge(wrapperEntities: AnyDraftEntity[] = []): void {
383
+ if (!this.#parent) {
384
+ throw new Error('Cannot merge: This builder is not a fork or has already been merged.');
385
+ }
386
+
387
+ const wrappedSegments: TextSegment[] = this.#segments.map((seg) => {
388
+ if (seg.text.length === 0) {
389
+ // Skip empty strings directly
390
+ return seg;
391
+ }
392
+
393
+ // Collect all original entities of this segment
394
+ const existing = seg.entities ? [...seg.entities] : seg.entity ? [seg.entity] : [];
395
+
396
+ return {
397
+ text: seg.text,
398
+ entities: [...existing, ...wrapperEntities],
399
+ };
400
+ });
401
+
402
+ this.#parent.addTextSegmentList(wrappedSegments);
403
+ this.#parent = undefined;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Abstract proxy class that delegates all IEntityBuilder operations to an underlying builder instance.
409
+ */
410
+ export abstract class EntityBuilderProxy<THIS extends IEntityBuilder = IEntityBuilder> implements IEntityBuilder {
411
+ protected constructor(protected _entities: IEntityBuilder) {}
412
+
413
+ public get entities(): IEntityBuilder {
414
+ return this._entities;
415
+ }
416
+
417
+ public addText(...args: Parameters<IEntityBuilder['addText']>): this {
418
+ this._entities.addText(...args);
419
+ return this;
420
+ }
421
+
422
+ public addTextEntity(...args: Parameters<IEntityBuilder['addTextEntity']>): this {
423
+ this._entities.addTextEntity(...args);
424
+ return this;
425
+ }
426
+
427
+ public addTextEntities(...args: Parameters<IEntityBuilder['addTextEntities']>): this {
428
+ this._entities.addTextEntities(...args);
429
+ return this;
430
+ }
431
+
432
+ public addTextSegmentList(...args: Parameters<IEntityBuilder['addTextSegmentList']>): this {
433
+ this._entities.addTextSegmentList(...args);
434
+ return this;
435
+ }
436
+
437
+ public sliceInplace(...args: Parameters<IEntityBuilder['sliceInplace']>): this {
438
+ this._entities.sliceInplace(...args);
439
+ return this;
440
+ }
441
+
442
+ public slice(...args: Parameters<IEntityBuilder['slice']>): THIS {
443
+ return this.fork().sliceInplace(...args) as THIS;
444
+ }
445
+
446
+ public trim(): this {
447
+ this._entities.trim();
448
+ return this;
449
+ }
450
+
451
+ public trimStart(): this {
452
+ this._entities.trimStart();
453
+ return this;
454
+ }
455
+
456
+ public trimEnd(): this {
457
+ this._entities.trimEnd();
458
+ return this;
459
+ }
460
+
461
+ public sortEntities(): this {
462
+ this._entities.sortEntities();
463
+ return this;
464
+ }
465
+
466
+ public clone(): THIS {
467
+ const newInstance = Object.create(this.constructor.prototype) as EntityBuilderProxy;
468
+ newInstance._entities.clone();
469
+ return newInstance as IEntityBuilder as THIS;
470
+ }
471
+
472
+ public fork() {
473
+ return this._entities.fork();
474
+ }
475
+
476
+ public merge(...args: Parameters<IEntityBuilder['merge']>) {
477
+ this._entities.merge(...args);
478
+ }
479
+
480
+ public buildTextPayload() {
481
+ return this._entities.buildTextPayload();
482
+ }
483
+
484
+ public buildCaptionPayload() {
485
+ const { text, entities } = this.buildTextPayload();
486
+ return {
487
+ caption: text,
488
+ caption_entities: entities,
489
+ };
490
+ }
491
+
492
+ public buildInlinePayload() {
493
+ const { text, entities, link_preview_options } = this.buildTextPayload();
494
+ return {
495
+ message_text: text,
496
+ entities: entities,
497
+ link_preview_options,
498
+ };
499
+ }
500
+ }
501
+
502
+ function upperFirst(input: string) {
503
+ return input.slice(0, 1).toUpperCase() + input.slice(1);
504
+ }
505
+
506
+ /**
507
+ * Escapes a string to be used as a valid tag by removing or replacing invalid characters.
508
+ */
509
+ export function escapeTag(input: string) {
510
+ // return input.replace(/[^\p{L}\p{N}_]/gu, "");
511
+ return input
512
+ .replaceAll(/["'′]/g, '') // It's => Its
513
+ .split(/[^\p{L}\p{N}_]/gu)
514
+ .map((s) => upperFirst(s))
515
+ .join('');
516
+ }
517
+
518
+ const SymbolMessageComposer = Symbol('MessageComposer');
519
+
520
+ /**
521
+ * A high-level composer that wraps an IEntityBuilder and provides additional features like tags and link preview options.
522
+ */
523
+ export class MessageComposer extends EntityBuilderProxy<MessageComposer> {
524
+ protected [SymbolMessageComposer] = true as const;
525
+
526
+ #upperTags = new Set<string>();
527
+ #tags: string[] = [];
528
+
529
+ /**
530
+ * Options for link preview generation.
531
+ */
532
+ public linkPreviewOptions?: TT.LinkPreviewOptions;
533
+
534
+ public constructor(entities?: IEntityBuilder & { [SymbolMessageComposer]?: never }) {
535
+ if (entities && SymbolMessageComposer in entities) {
536
+ throw new Error('Cannot nest MessageComposer inside MessageComposer');
537
+ }
538
+ super(entities ?? new EntityBuilder());
539
+ }
540
+
541
+ /**
542
+ * Gets the current list of tags.
543
+ */
544
+ public get tags(): string[] {
545
+ return [...this.#tags];
546
+ }
547
+
548
+ /**
549
+ * Adds one or more tags, escaping them and avoiding duplicates.
550
+ */
551
+ public addTags(...tags: string[]) {
552
+ for (let tag of tags) {
553
+ tag = escapeTag(tag);
554
+ const upperTag = tag.toUpperCase();
555
+ if (this.#upperTags.has(upperTag)) {
556
+ // Deduplicate
557
+ continue;
558
+ }
559
+ this.#upperTags.add(upperTag);
560
+ this.#tags.push(tag);
561
+ }
562
+
563
+ return this;
564
+ }
565
+
566
+ public override buildTextPayload() {
567
+ const entities = this.entities.clone();
568
+
569
+ entities.trimEnd().addText('\n\n');
570
+
571
+ for (const tag of this.tags) {
572
+ entities.addTextEntity(`#${tag}`, { type: 'hashtag' }).addText(' ');
573
+ }
574
+
575
+ entities.trimEnd();
576
+
577
+ return {
578
+ ...entities.buildTextPayload(),
579
+ link_preview_options: this.linkPreviewOptions,
580
+ };
581
+ }
582
+ }