telegix 1.1.1

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/lib/rich.js ADDED
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Telegix - Rich Message & Draft Builder Suite
3
+ * Modern structured rich messages, cards, drafts, and layout blocks for Telegram Bot API.
4
+ * @module telegix/rich
5
+ */
6
+
7
+ import { escapeHtml, html } from './format.js';
8
+ import { Markup } from './markup.js';
9
+
10
+ export class RichMessageBuilder {
11
+ constructor(initialText = '') {
12
+ this.blocks = [];
13
+ this._text = initialText ? String(initialText) : '';
14
+ this._parseMode = 'HTML';
15
+ this._inlineKeyboard = [];
16
+ this._draftId = null;
17
+ this._ephemeral = null;
18
+ this._media = null;
19
+ this._extra = {};
20
+ }
21
+
22
+ /**
23
+ * Set parse mode ('HTML', 'MarkdownV2', etc.)
24
+ * @param {string} mode
25
+ * @returns {this}
26
+ */
27
+ parseMode(mode) {
28
+ this._parseMode = mode;
29
+ return this;
30
+ }
31
+
32
+ /**
33
+ * Set primary text
34
+ * @param {string} text
35
+ * @returns {this}
36
+ */
37
+ text(text) {
38
+ this._text = String(text);
39
+ return this;
40
+ }
41
+
42
+ /**
43
+ * Add a header block with optional emoji
44
+ * @param {string} text
45
+ * @param {string} [emoji]
46
+ * @returns {this}
47
+ */
48
+ header(text, emoji = '') {
49
+ const formatted = emoji ? `${emoji} ${text}` : text;
50
+ this.blocks.push({
51
+ type: 'header',
52
+ content: formatted,
53
+ rawHtml: `<b>${escapeHtml(formatted)}</b>`,
54
+ });
55
+ return this;
56
+ }
57
+
58
+ /**
59
+ * Add a paragraph block
60
+ * @param {string} text
61
+ * @returns {this}
62
+ */
63
+ paragraph(text) {
64
+ this.blocks.push({
65
+ type: 'paragraph',
66
+ content: text,
67
+ rawHtml: escapeHtml(text),
68
+ });
69
+ return this;
70
+ }
71
+
72
+ /**
73
+ * Add bold text block
74
+ * @param {string} text
75
+ * @returns {this}
76
+ */
77
+ bold(text) {
78
+ this.blocks.push({
79
+ type: 'bold',
80
+ content: text,
81
+ rawHtml: `<b>${escapeHtml(text)}</b>`,
82
+ });
83
+ return this;
84
+ }
85
+
86
+ /**
87
+ * Add italic text block
88
+ * @param {string} text
89
+ * @returns {this}
90
+ */
91
+ italic(text) {
92
+ this.blocks.push({
93
+ type: 'italic',
94
+ content: text,
95
+ rawHtml: `<i>${escapeHtml(text)}</i>`,
96
+ });
97
+ return this;
98
+ }
99
+
100
+ /**
101
+ * Add underline text block
102
+ * @param {string} text
103
+ * @returns {this}
104
+ */
105
+ underline(text) {
106
+ this.blocks.push({
107
+ type: 'underline',
108
+ content: text,
109
+ rawHtml: `<u>${escapeHtml(text)}</u>`,
110
+ });
111
+ return this;
112
+ }
113
+
114
+ /**
115
+ * Add strikethrough text block
116
+ * @param {string} text
117
+ * @returns {this}
118
+ */
119
+ strikethrough(text) {
120
+ this.blocks.push({
121
+ type: 'strikethrough',
122
+ content: text,
123
+ rawHtml: `<s>${escapeHtml(text)}</s>`,
124
+ });
125
+ return this;
126
+ }
127
+
128
+ /**
129
+ * Add code block or inline code
130
+ * @param {string} codeText
131
+ * @param {string} [language]
132
+ * @returns {this}
133
+ */
134
+ code(codeText, language = '') {
135
+ const isMultiline = String(codeText).includes('\n') || Boolean(language);
136
+ this.blocks.push({
137
+ type: 'code',
138
+ content: codeText,
139
+ language,
140
+ rawHtml: isMultiline
141
+ ? html.pre(codeText, language)
142
+ : html.code(codeText),
143
+ });
144
+ return this;
145
+ }
146
+
147
+ /**
148
+ * Add a blockquote block
149
+ * @param {string} text
150
+ * @param {boolean} [expandable=false]
151
+ * @returns {this}
152
+ */
153
+ quote(text, expandable = false) {
154
+ this.blocks.push({
155
+ type: 'quote',
156
+ content: text,
157
+ expandable,
158
+ rawHtml: expandable
159
+ ? `<blockquote expandable>${escapeHtml(text)}</blockquote>`
160
+ : `<blockquote>${escapeHtml(text)}</blockquote>`,
161
+ });
162
+ return this;
163
+ }
164
+
165
+ /**
166
+ * Add an expandable blockquote
167
+ * @param {string} text
168
+ * @returns {this}
169
+ */
170
+ expandableQuote(text) {
171
+ return this.quote(text, true);
172
+ }
173
+
174
+ /**
175
+ * Add spoiler block
176
+ * @param {string} text
177
+ * @returns {this}
178
+ */
179
+ spoiler(text) {
180
+ this.blocks.push({
181
+ type: 'spoiler',
182
+ content: text,
183
+ rawHtml: `<span class="tg-spoiler">${escapeHtml(text)}</span>`,
184
+ });
185
+ return this;
186
+ }
187
+
188
+ /**
189
+ * Add formatted link
190
+ * @param {string} text
191
+ * @param {string} url
192
+ * @returns {this}
193
+ */
194
+ link(text, url) {
195
+ this.blocks.push({
196
+ type: 'link',
197
+ text,
198
+ url,
199
+ rawHtml: `<a href="${escapeHtml(url)}">${escapeHtml(text)}</a>`,
200
+ });
201
+ return this;
202
+ }
203
+
204
+ /**
205
+ * Add user mention
206
+ * @param {string} text
207
+ * @param {number|string} userId
208
+ * @returns {this}
209
+ */
210
+ mention(text, userId) {
211
+ this.blocks.push({
212
+ type: 'mention',
213
+ text,
214
+ userId,
215
+ rawHtml: `<a href="tg://user?id=${userId}">${escapeHtml(text)}</a>`,
216
+ });
217
+ return this;
218
+ }
219
+
220
+ /**
221
+ * Add bullet list
222
+ * @param {Array<string>} items
223
+ * @param {string} [bullet='•']
224
+ * @returns {this}
225
+ */
226
+ list(items, bullet = '•') {
227
+ const listItems = Array.isArray(items) ? items : [items];
228
+ const htmlLines = listItems.map((item) => `${bullet} ${escapeHtml(item)}`).join('\n');
229
+ this.blocks.push({
230
+ type: 'list',
231
+ items: listItems,
232
+ bullet,
233
+ rawHtml: htmlLines,
234
+ });
235
+ return this;
236
+ }
237
+
238
+ /**
239
+ * Add numbered list
240
+ * @param {Array<string>} items
241
+ * @returns {this}
242
+ */
243
+ numberedList(items) {
244
+ const listItems = Array.isArray(items) ? items : [items];
245
+ const htmlLines = listItems.map((item, idx) => `<b>${idx + 1}.</b> ${escapeHtml(item)}`).join('\n');
246
+ this.blocks.push({
247
+ type: 'numbered_list',
248
+ items: listItems,
249
+ rawHtml: htmlLines,
250
+ });
251
+ return this;
252
+ }
253
+
254
+ /**
255
+ * Add badge / stat item
256
+ * @param {string} label
257
+ * @param {string|number} value
258
+ * @param {string} [icon]
259
+ * @returns {this}
260
+ */
261
+ badge(label, value, icon = '') {
262
+ const iconPrefix = icon ? `${icon} ` : '';
263
+ this.blocks.push({
264
+ type: 'badge',
265
+ label,
266
+ value,
267
+ rawHtml: `${iconPrefix}<b>${escapeHtml(label)}:</b> <code>${escapeHtml(value)}</code>`,
268
+ });
269
+ return this;
270
+ }
271
+
272
+ /**
273
+ * Add divider line
274
+ * @returns {this}
275
+ */
276
+ divider() {
277
+ this.blocks.push({
278
+ type: 'divider',
279
+ rawHtml: '───────────────',
280
+ });
281
+ return this;
282
+ }
283
+
284
+ /**
285
+ * Attach photo or media
286
+ * @param {string} url
287
+ * @param {string} [caption]
288
+ * @returns {this}
289
+ */
290
+ photo(url, caption = '') {
291
+ this._media = { type: 'photo', url, caption };
292
+ return this;
293
+ }
294
+
295
+ /**
296
+ * Set ephemeral parameters (disappearing message)
297
+ * @param {number|object} lifetimeSecondsOrParams
298
+ * @returns {this}
299
+ */
300
+ ephemeral(lifetimeSecondsOrParams = 60) {
301
+ if (typeof lifetimeSecondsOrParams === 'number') {
302
+ this._ephemeral = { lifetime: lifetimeSecondsOrParams };
303
+ } else {
304
+ this._ephemeral = lifetimeSecondsOrParams;
305
+ }
306
+ return this;
307
+ }
308
+
309
+ /**
310
+ * Set draft ID or configure as draft
311
+ * @param {number} [draftId]
312
+ * @returns {this}
313
+ */
314
+ draftId(draftId) {
315
+ this._draftId = draftId ?? Math.floor(Math.random() * 2147483647) + 1;
316
+ return this;
317
+ }
318
+
319
+ /**
320
+ * Mark as draft with auto-generated ID
321
+ * @returns {this}
322
+ */
323
+ asDraft() {
324
+ return this.draftId();
325
+ }
326
+
327
+ /**
328
+ * Add a single inline button or row of buttons
329
+ * @param {object|Array<object>} buttons
330
+ * @returns {this}
331
+ */
332
+ button(buttons) {
333
+ if (Array.isArray(buttons)) {
334
+ this._inlineKeyboard.push(buttons);
335
+ } else {
336
+ this._inlineKeyboard.push([buttons]);
337
+ }
338
+ return this;
339
+ }
340
+
341
+ /**
342
+ * Add a row of inline buttons
343
+ * @param {...object} buttons
344
+ * @returns {this}
345
+ */
346
+ row(...buttons) {
347
+ if (buttons.length > 0) {
348
+ this._inlineKeyboard.push(buttons);
349
+ }
350
+ return this;
351
+ }
352
+
353
+ /**
354
+ * Add callback query button
355
+ * @param {string} text
356
+ * @param {string} data
357
+ * @returns {this}
358
+ */
359
+ callback(text, data) {
360
+ return this.button(Markup.button.callback(text, data));
361
+ }
362
+
363
+ /**
364
+ * Add URL button
365
+ * @param {string} text
366
+ * @param {string} url
367
+ * @returns {this}
368
+ */
369
+ url(text, url) {
370
+ return this.button(Markup.button.url(text, url));
371
+ }
372
+
373
+ /**
374
+ * Add Bot API 10.3 disabled button
375
+ * @param {string} text
376
+ * @returns {this}
377
+ */
378
+ disabled(text) {
379
+ return this.button(Markup.button.disabled(text));
380
+ }
381
+
382
+ /**
383
+ * Add Web App button
384
+ * @param {string} text
385
+ * @param {string} webAppUrl
386
+ * @returns {this}
387
+ */
388
+ webApp(text, webAppUrl) {
389
+ return this.button(Markup.button.webApp(text, webAppUrl));
390
+ }
391
+
392
+ /**
393
+ * Add Copy Text button
394
+ * @param {string} text
395
+ * @param {string} textToCopy
396
+ * @returns {this}
397
+ */
398
+ copyText(text, textToCopy) {
399
+ return this.button(Markup.button.copyText(text, textToCopy));
400
+ }
401
+
402
+ /**
403
+ * Add full inline keyboard matrix
404
+ * @param {Array<Array<object>>} matrix
405
+ * @returns {this}
406
+ */
407
+ keyboard(matrix) {
408
+ if (Array.isArray(matrix)) {
409
+ this._inlineKeyboard = matrix;
410
+ }
411
+ return this;
412
+ }
413
+
414
+ /**
415
+ * Set custom reply markup (e.g. from Markup helper)
416
+ * @param {object} markup
417
+ * @returns {this}
418
+ */
419
+ replyMarkup(markup) {
420
+ this._customReplyMarkup = markup;
421
+ return this;
422
+ }
423
+
424
+ /**
425
+ * Set extra options
426
+ * @param {object} extra
427
+ * @returns {this}
428
+ */
429
+ extra(extra) {
430
+ this._extra = { ...this._extra, ...extra };
431
+ return this;
432
+ }
433
+
434
+ /**
435
+ * Compile all rich blocks into standard HTML text for compatibility
436
+ * @returns {string}
437
+ */
438
+ compileHtml() {
439
+ const parts = [];
440
+ if (this._text) {
441
+ parts.push(this._text);
442
+ }
443
+ for (const block of this.blocks) {
444
+ if (block.rawHtml) {
445
+ parts.push(block.rawHtml);
446
+ }
447
+ }
448
+ return parts.join('\n\n');
449
+ }
450
+
451
+ /**
452
+ * Compile payload ready for Telegram Bot API
453
+ * @returns {object}
454
+ */
455
+ compile() {
456
+ const compiledText = this.compileHtml();
457
+ const replyMarkup =
458
+ this._customReplyMarkup ||
459
+ (this._inlineKeyboard.length > 0
460
+ ? { inline_keyboard: this._inlineKeyboard }
461
+ : undefined);
462
+
463
+ const payload = {
464
+ text: compiledText || ' ',
465
+ parse_mode: this._parseMode,
466
+ blocks: this.blocks.map((b) => ({
467
+ type: b.type,
468
+ content: b.content,
469
+ language: b.language,
470
+ expandable: b.expandable,
471
+ items: b.items,
472
+ label: b.label,
473
+ value: b.value,
474
+ })),
475
+ ...this._extra,
476
+ };
477
+
478
+ if (replyMarkup) {
479
+ payload.reply_markup = replyMarkup;
480
+ }
481
+
482
+ if (this._ephemeral) {
483
+ payload.ephemeral_parameters = this._ephemeral;
484
+ }
485
+
486
+ if (this._draftId !== null) {
487
+ payload.draft_id = this._draftId;
488
+ }
489
+
490
+ return payload;
491
+ }
492
+
493
+ /**
494
+ * Build plain structured Rich Message object
495
+ * @returns {object}
496
+ */
497
+ build() {
498
+ return this.compile();
499
+ }
500
+
501
+ /**
502
+ * JSON serialization
503
+ */
504
+ toJSON() {
505
+ return this.compile();
506
+ }
507
+
508
+ /**
509
+ * Send this rich message to a chat
510
+ * @param {import('./context.js').Context} ctx
511
+ * @param {number|string} [chatId]
512
+ * @param {object} [extra]
513
+ */
514
+ async send(ctx, chatId, extra = {}) {
515
+ const targetChatId = chatId || ctx.chat?.id || ctx.chatId;
516
+ if (!targetChatId) {
517
+ throw new Error('RichMessage.send() requires a target chatId or active Context.');
518
+ }
519
+ const compiled = this.compile();
520
+ return ctx.telegram.sendRichMessage(targetChatId, compiled, extra);
521
+ }
522
+
523
+ /**
524
+ * Send this rich message as a draft
525
+ * @param {import('./context.js').Context} ctx
526
+ * @param {number|string} [chatId]
527
+ * @param {object} [extra]
528
+ */
529
+ async sendDraft(ctx, chatId, extra = {}) {
530
+ const targetChatId = chatId || ctx.chat?.id || ctx.chatId;
531
+ if (!targetChatId) {
532
+ throw new Error('RichMessage.sendDraft() requires a target chatId or active Context.');
533
+ }
534
+ const compiled = this.compile();
535
+ return ctx.telegram.sendRichMessageDraft(targetChatId, compiled, extra);
536
+ }
537
+
538
+ /**
539
+ * Edit an existing message with this rich message
540
+ * @param {import('./context.js').Context} ctx
541
+ * @param {number} [messageId]
542
+ * @param {object} [extra]
543
+ */
544
+ async edit(ctx, messageId, extra = {}) {
545
+ const targetChatId = ctx.chat?.id || ctx.chatId;
546
+ const targetMessageId = messageId || ctx.message?.id || ctx.msg?.id;
547
+ if (!targetChatId || !targetMessageId) {
548
+ throw new Error('RichMessage.edit() requires chatId and messageId.');
549
+ }
550
+ const compiled = this.compile();
551
+ return ctx.telegram.editRichMessageText(targetChatId, targetMessageId, compiled, extra);
552
+ }
553
+
554
+ // ==========================================
555
+ // Static Factory Methods
556
+ // ==========================================
557
+
558
+ /**
559
+ * Create a new RichMessage instance
560
+ * @param {string} [initialText]
561
+ * @returns {RichMessageBuilder}
562
+ */
563
+ static create(initialText) {
564
+ return new RichMessageBuilder(initialText);
565
+ }
566
+
567
+ /**
568
+ * Create a pre-configured interactive Card
569
+ * @param {string} title
570
+ * @param {string} description
571
+ * @param {Array<object>} [buttons=[]]
572
+ * @returns {RichMessageBuilder}
573
+ */
574
+ static card(title, description, buttons = []) {
575
+ const builder = new RichMessageBuilder();
576
+ builder.header(title);
577
+ if (description) {
578
+ builder.paragraph(description);
579
+ }
580
+ if (buttons.length > 0) {
581
+ builder.row(...buttons);
582
+ }
583
+ return builder;
584
+ }
585
+
586
+ /**
587
+ * Create a draft message builder
588
+ * @param {string} text
589
+ * @param {number} [draftId]
590
+ * @returns {RichMessageBuilder}
591
+ */
592
+ static draft(text, draftId) {
593
+ const builder = new RichMessageBuilder(text);
594
+ return builder.draftId(draftId);
595
+ }
596
+
597
+ /**
598
+ * Create an ephemeral disappearing message builder
599
+ * @param {string} text
600
+ * @param {number} [lifetimeSeconds=60]
601
+ * @returns {RichMessageBuilder}
602
+ */
603
+ static ephemeral(text, lifetimeSeconds = 60) {
604
+ const builder = new RichMessageBuilder(text);
605
+ return builder.ephemeral(lifetimeSeconds);
606
+ }
607
+ }
608
+
609
+ export const RichMessage = RichMessageBuilder;