telegram-writer 0.0.0-stage → 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.
@@ -0,0 +1,1132 @@
1
+ 'use strict';
2
+
3
+ var diKit = require('di-kit');
4
+ var readline = require('readline');
5
+ var promises = require('fs/promises');
6
+ var qrcodeTerminal = require('qrcode-terminal');
7
+ var functoolsKit = require('functools-kit');
8
+ var sharp = require('sharp');
9
+
10
+ const { init, inject, provide } = diKit.createActivator("tg-writer");
11
+
12
+ const baseServices$1 = {
13
+ authService: Symbol('authService'),
14
+ loggerService: Symbol('loggerService'),
15
+ markdownService: Symbol('markdownService'),
16
+ writerService: Symbol('writerService'),
17
+ };
18
+ const globalServices$1 = {
19
+ telegramGlobalService: Symbol('telegramGlobalService'),
20
+ };
21
+ const TYPES = {
22
+ ...baseServices$1,
23
+ ...globalServices$1,
24
+ };
25
+
26
+ const GLOBAL_CONFIG = {
27
+ CC_TELEGRAM_API_ID: 0,
28
+ CC_TELEGRAM_API_HASH: "",
29
+ /** Max characters of a plain text message. Telegram hard limit is 4096 */
30
+ CC_TELEGRAM_MESSAGE_LIMIT: 0,
31
+ /** Max characters of a photo caption. Telegram limit is 1024 (4096 for premium accounts) */
32
+ CC_TELEGRAM_CAPTION_LIMIT: 0,
33
+ };
34
+ Object.freeze({ ...GLOBAL_CONFIG });
35
+ const getConfig = () => {
36
+ const config = {
37
+ CC_TELEGRAM_API_ID: parseInt(process.env.CC_TELEGRAM_API_ID) || 31861455,
38
+ CC_TELEGRAM_API_HASH: process.env.CC_TELEGRAM_API_HASH || "ca60446c67ce250ee4e789c730163449",
39
+ CC_TELEGRAM_MESSAGE_LIMIT: parseInt(process.env.CC_TELEGRAM_MESSAGE_LIMIT) || 4096,
40
+ CC_TELEGRAM_CAPTION_LIMIT: parseInt(process.env.CC_TELEGRAM_CAPTION_LIMIT) || 1024,
41
+ };
42
+ if (GLOBAL_CONFIG.CC_TELEGRAM_API_ID) {
43
+ config.CC_TELEGRAM_API_ID = GLOBAL_CONFIG.CC_TELEGRAM_API_ID;
44
+ }
45
+ if (GLOBAL_CONFIG.CC_TELEGRAM_API_HASH) {
46
+ config.CC_TELEGRAM_API_HASH = GLOBAL_CONFIG.CC_TELEGRAM_API_HASH;
47
+ }
48
+ if (GLOBAL_CONFIG.CC_TELEGRAM_MESSAGE_LIMIT) {
49
+ config.CC_TELEGRAM_MESSAGE_LIMIT = GLOBAL_CONFIG.CC_TELEGRAM_MESSAGE_LIMIT;
50
+ }
51
+ if (GLOBAL_CONFIG.CC_TELEGRAM_CAPTION_LIMIT) {
52
+ config.CC_TELEGRAM_CAPTION_LIMIT = GLOBAL_CONFIG.CC_TELEGRAM_CAPTION_LIMIT;
53
+ }
54
+ return config;
55
+ };
56
+ const setConfig = (config) => {
57
+ Object.assign(GLOBAL_CONFIG, config);
58
+ };
59
+
60
+ const HANDLE_SIGNIN_FN = async () => {
61
+ // Ленивый импорт: у gramjs нет exports-карты, подпути в ESM резолвятся
62
+ // только по полному пути с расширением
63
+ const { TelegramClient } = await import('telegram');
64
+ const { StringSession } = await import('telegram/sessions/index.js');
65
+ const { CC_TELEGRAM_API_ID, CC_TELEGRAM_API_HASH } = getConfig();
66
+ const stringSession = new StringSession("");
67
+ const rl = readline.createInterface({
68
+ input: process.stdin,
69
+ output: process.stdout,
70
+ });
71
+ const client = new TelegramClient(stringSession, CC_TELEGRAM_API_ID, CC_TELEGRAM_API_HASH, {
72
+ connectionRetries: 5,
73
+ systemVersion: "Windows 10",
74
+ deviceModel: "Desktop",
75
+ appVersion: "1.0.0",
76
+ });
77
+ await client.connect();
78
+ await client.signInUserWithQrCode({ apiId: CC_TELEGRAM_API_ID, apiHash: CC_TELEGRAM_API_HASH }, {
79
+ qrCode: async ({ token }) => {
80
+ const url = `tg://login?token=${token.toString("base64url")}`;
81
+ console.clear();
82
+ console.log("Scan this QR code in Telegram app (Settings -> Devices -> Link Desktop Device):\n");
83
+ qrcodeTerminal.generate(url, { small: true });
84
+ },
85
+ password: async () => new Promise((resolve) => rl.question("Enter your 2FA password: ", resolve)),
86
+ onError: async (err) => {
87
+ console.error(err.message);
88
+ return false;
89
+ },
90
+ });
91
+ console.log("Connected!");
92
+ {
93
+ const sessionString = stringSession.save();
94
+ await promises.writeFile("./session.txt", sessionString, "utf-8");
95
+ }
96
+ console.log("Session saved to ./session.txt");
97
+ rl.close();
98
+ };
99
+ class AuthService {
100
+ constructor() {
101
+ this.loggerService = inject(TYPES.loggerService);
102
+ this.signIn = async () => {
103
+ this.loggerService.log("authService signIn");
104
+ await HANDLE_SIGNIN_FN();
105
+ };
106
+ }
107
+ }
108
+
109
+ const NOOP_LOGGER = {
110
+ log() {
111
+ },
112
+ debug() {
113
+ },
114
+ info() {
115
+ },
116
+ warn() {
117
+ },
118
+ };
119
+ class LoggerService {
120
+ constructor() {
121
+ this._commonLogger = NOOP_LOGGER;
122
+ this.log = async (topic, ...args) => {
123
+ await this._commonLogger.log(topic, ...args);
124
+ };
125
+ this.debug = async (topic, ...args) => {
126
+ await this._commonLogger.debug(topic, ...args);
127
+ };
128
+ this.info = async (topic, ...args) => {
129
+ await this._commonLogger.info(topic, ...args);
130
+ };
131
+ this.warn = async (topic, ...args) => {
132
+ await this._commonLogger.warn(topic, ...args);
133
+ };
134
+ this.setLogger = (logger) => {
135
+ this._commonLogger = logger;
136
+ };
137
+ }
138
+ }
139
+
140
+ // Разделитель блоков в итоговом сообщении: пустая строка между абзацами
141
+ const SEPARATOR = "\n\n";
142
+ // Маркер ненумерованного списка и отступ вложенного уровня. Telegram сохраняет
143
+ // пробелы как есть, так что вложенность видна без спецсимволов
144
+ const BULLET = "• ";
145
+ const LIST_INDENT = " ";
146
+ // Telegram принимает в ссылках только абсолютные URL этих схем; относительные
147
+ // и javascript: ссылки разворачиваем до простого текста
148
+ const ALLOWED_URL = /^(https?:\/\/|tg:\/\/|mailto:)/i;
149
+ // Язык подсветки в ```lang — только безопасные символы, иначе атрибут class
150
+ // можно было бы сломать кавычкой
151
+ const LANGUAGE_PATTERN = /(?:^|\s)language-([\w+#.-]+)/;
152
+ // Заголовки: в Telegram нет h1-h6, рендерим жирной строкой
153
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
154
+ // Блочные теги HTML: при встрече инлайн-поток переносится на новую строку
155
+ const BLOCK_TAGS = new Set([
156
+ "p", "div", "section", "article", "header", "footer", "main", "aside", "nav",
157
+ "figure", "figcaption", "details", "summary", "address", "form", "fieldset",
158
+ "dl", "dt", "dd", "li", "center", "body", "html",
159
+ ]);
160
+ // Содержимое этих тегов в тексте поста не нужно никогда
161
+ const SKIP_TAGS = new Set([
162
+ "script", "style", "template", "head", "title", "meta", "link", "noscript",
163
+ "iframe", "object", "embed", "svg", "math", "canvas", "video", "audio",
164
+ ]);
165
+ // Ленивые импорты: marked>=16 и htmlparser2>=11 собраны только как ESM.
166
+ // Через await import они одинаково работают и из cjs-, и из esm-сборки.
167
+ const getMarked = functoolsKit.singleshot(async () => {
168
+ const { Marked } = await import('marked');
169
+ // breaks: одиночный перенос строки становится <br>. В чате перенос ожидают
170
+ // буквально, а не как «мягкий» пробел по спецификации CommonMark
171
+ return new Marked({ gfm: true, breaks: true });
172
+ });
173
+ const getParseDocument = functoolsKit.singleshot(async () => {
174
+ const { parseDocument } = await import('htmlparser2');
175
+ return parseDocument;
176
+ });
177
+ const isElement = (node) => node.type === "tag" || node.type === "script" || node.type === "style";
178
+ const isText = (node) => node.type === "text";
179
+ const tagName = (node) => (node.name || "").toLowerCase();
180
+ const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
181
+ const escapeAttr = (text) => escapeHtml(text).replace(/"/g, "&quot;");
182
+ const charCount = (text) => Array.from(text).length;
183
+ const textContent = (node) => {
184
+ if (isText(node)) {
185
+ return node.data || "";
186
+ }
187
+ if (isElement(node) && SKIP_TAGS.has(tagName(node))) {
188
+ return "";
189
+ }
190
+ return (node.children || []).map(textContent).join("");
191
+ };
192
+ const sanitizeUrl = (url) => {
193
+ if (!url) {
194
+ return undefined;
195
+ }
196
+ const trimmed = url.trim();
197
+ return ALLOWED_URL.test(trimmed) ? trimmed : undefined;
198
+ };
199
+ const sameStyle = (a, b) => !!a.bold === !!b.bold &&
200
+ !!a.italic === !!b.italic &&
201
+ !!a.underline === !!b.underline &&
202
+ !!a.strike === !!b.strike &&
203
+ !!a.spoiler === !!b.spoiler &&
204
+ !!a.code === !!b.code &&
205
+ (a.href || "") === (b.href || "");
206
+ const runsLength = (runs) => runs.reduce((acc, run) => acc + run.text.length, 0);
207
+ /**
208
+ * Убирает пустые отрезки и пробельные края списка отрезков, не трогая
209
+ * внутренние переносы (отступы вложенных списков начинаются с пробелов).
210
+ */
211
+ const trimEdges = (runs) => {
212
+ const result = runs.filter((run) => run.text);
213
+ while (result.length && !result[0].text.trim()) {
214
+ result.shift();
215
+ }
216
+ if (result.length) {
217
+ result[0] = { ...result[0], text: result[0].text.replace(/^\s+/, "") };
218
+ }
219
+ while (result.length && !result[result.length - 1].text.trim()) {
220
+ result.pop();
221
+ }
222
+ if (result.length) {
223
+ const last = result.length - 1;
224
+ result[last] = { ...result[last], text: result[last].text.replace(/\s+$/, "") };
225
+ }
226
+ return result;
227
+ };
228
+ /**
229
+ * Склеивает соседние отрезки с одинаковым стилем, убирает хвостовые пробелы
230
+ * перед переносами, схлопывает три и более переносов до пустой строки и
231
+ * обрезает края. Результат — канонический вид блока: один тег на стиль,
232
+ * без вложенных одноимённых тегов, которые парсер gramjs не различает.
233
+ */
234
+ const normalizeRuns = (runs) => {
235
+ const merged = [];
236
+ for (const run of runs) {
237
+ if (!run.text) {
238
+ continue;
239
+ }
240
+ const prev = merged[merged.length - 1];
241
+ if (prev && sameStyle(prev, run)) {
242
+ prev.text += run.text;
243
+ continue;
244
+ }
245
+ merged.push({ ...run });
246
+ }
247
+ let newlines = 0;
248
+ for (const run of merged) {
249
+ let out = "";
250
+ for (const ch of run.text.replace(/[ \t]+\n/g, "\n")) {
251
+ if (ch === "\n") {
252
+ newlines += 1;
253
+ if (newlines <= 2) {
254
+ out += ch;
255
+ }
256
+ continue;
257
+ }
258
+ newlines = 0;
259
+ out += ch;
260
+ }
261
+ run.text = out;
262
+ }
263
+ return trimEdges(merged);
264
+ };
265
+ const isEmptyBlock = (block) => block.type === "pre" ? !block.text.trim() : block.runs.length === 0;
266
+ /**
267
+ * Накопитель блоков. Инлайновые узлы верхнего уровня (сырой HTML вроде
268
+ * <b>x</b> вне абзаца) собираются в неявный абзац, который закрывается
269
+ * ближайшим блочным элементом.
270
+ */
271
+ class BlockCollector {
272
+ constructor() {
273
+ this.blocks = [];
274
+ this.pending = null;
275
+ }
276
+ get hasPending() {
277
+ return this.pending !== null;
278
+ }
279
+ inline(runs) {
280
+ if (!this.pending) {
281
+ this.pending = [];
282
+ }
283
+ this.pending.push(...runs);
284
+ }
285
+ flush() {
286
+ if (!this.pending) {
287
+ return;
288
+ }
289
+ const runs = normalizeRuns(this.pending);
290
+ this.pending = null;
291
+ if (runs.length) {
292
+ this.blocks.push({ type: "paragraph", runs });
293
+ }
294
+ }
295
+ push(block) {
296
+ this.flush();
297
+ if (!isEmptyBlock(block)) {
298
+ this.blocks.push(block);
299
+ }
300
+ }
301
+ }
302
+ /**
303
+ * Собирает инлайновое содержимое узлов в отрезки текста. Блочные элементы,
304
+ * встреченные внутри инлайна (например <p> в <li>), становятся переносами строк.
305
+ */
306
+ const collectInline = (nodes, style) => {
307
+ const out = [];
308
+ const text = (value, override = style) => {
309
+ if (value) {
310
+ out.push({ ...override, text: value });
311
+ }
312
+ };
313
+ // Гарантирует, что дальнейший текст начнётся с новой строки, но не
314
+ // добавляет перенос в самое начало и не дублирует уже стоящий
315
+ const newline = () => {
316
+ const tail = out.map((run) => run.text).join("").replace(/[ \t]+$/, "");
317
+ if (!tail.trim() || tail.endsWith("\n")) {
318
+ return;
319
+ }
320
+ out.push({ text: "\n" });
321
+ };
322
+ for (const node of nodes) {
323
+ if (isText(node)) {
324
+ text(node.data || "");
325
+ continue;
326
+ }
327
+ if (!isElement(node)) {
328
+ continue;
329
+ }
330
+ const name = tagName(node);
331
+ const children = node.children || [];
332
+ const attribs = node.attribs || {};
333
+ if (SKIP_TAGS.has(name)) {
334
+ continue;
335
+ }
336
+ switch (name) {
337
+ case "br":
338
+ out.push({ text: "\n" });
339
+ break;
340
+ case "strong":
341
+ case "b":
342
+ out.push(...collectInline(children, { ...style, bold: true }));
343
+ break;
344
+ case "em":
345
+ case "i":
346
+ out.push(...collectInline(children, { ...style, italic: true }));
347
+ break;
348
+ case "u":
349
+ case "ins":
350
+ out.push(...collectInline(children, { ...style, underline: true }));
351
+ break;
352
+ case "s":
353
+ case "del":
354
+ case "strike":
355
+ out.push(...collectInline(children, { ...style, strike: true }));
356
+ break;
357
+ case "code":
358
+ case "kbd":
359
+ case "samp":
360
+ case "tt":
361
+ out.push(...collectInline(children, { ...style, code: true }));
362
+ break;
363
+ case "spoiler":
364
+ case "tg-spoiler":
365
+ out.push(...collectInline(children, { ...style, spoiler: true }));
366
+ break;
367
+ case "span": {
368
+ const classes = (attribs.class || "").split(/\s+/);
369
+ const spoiler = classes.includes("tg-spoiler");
370
+ out.push(...collectInline(children, spoiler ? { ...style, spoiler: true } : style));
371
+ break;
372
+ }
373
+ case "a": {
374
+ const href = sanitizeUrl(attribs.href);
375
+ out.push(...collectInline(children, href ? { ...style, href } : style));
376
+ break;
377
+ }
378
+ case "img": {
379
+ // Картинку внутрь текста Telegram не вставит: оставляем подпись
380
+ // ссылкой на источник, чтобы читатель мог её открыть
381
+ const src = sanitizeUrl(attribs.src);
382
+ const alt = (attribs.alt || "").trim();
383
+ const label = alt || src || "";
384
+ text(label, src ? { ...style, href: src } : style);
385
+ break;
386
+ }
387
+ case "input":
388
+ if ((attribs.type || "").toLowerCase() === "checkbox") {
389
+ text("checked" in attribs ? "☑" : "☐");
390
+ }
391
+ break;
392
+ case "ul":
393
+ case "ol":
394
+ newline();
395
+ out.push(...collectList(node, style, 0));
396
+ newline();
397
+ break;
398
+ case "pre":
399
+ newline();
400
+ text(collectPre(node).text, { ...style, code: true });
401
+ newline();
402
+ break;
403
+ case "table":
404
+ newline();
405
+ text(renderTable(node), { ...style, code: true });
406
+ newline();
407
+ break;
408
+ case "hr":
409
+ newline();
410
+ break;
411
+ default:
412
+ if (HEADING_TAGS.has(name)) {
413
+ newline();
414
+ out.push(...collectInline(children, { ...style, bold: true }));
415
+ newline();
416
+ break;
417
+ }
418
+ if (BLOCK_TAGS.has(name) || name === "blockquote") {
419
+ newline();
420
+ out.push(...collectInline(children, style));
421
+ newline();
422
+ break;
423
+ }
424
+ // Неизвестный инлайн-тег: тег выбрасываем, текст оставляем
425
+ out.push(...collectInline(children, style));
426
+ }
427
+ }
428
+ return out;
429
+ };
430
+ /**
431
+ * Список в строки с маркерами. Вложенные списки получают отступ по уровню,
432
+ * нумерация учитывает атрибут start.
433
+ */
434
+ const collectList = (list, style, depth) => {
435
+ const ordered = tagName(list) === "ol";
436
+ let index = parseInt((list.attribs || {}).start || "1", 10) || 1;
437
+ const out = [];
438
+ const items = (list.children || []).filter((node) => isElement(node) && tagName(node) === "li");
439
+ for (const item of items) {
440
+ if (out.length) {
441
+ out.push({ text: "\n" });
442
+ }
443
+ const marker = ordered ? `${index}. ` : BULLET;
444
+ index += 1;
445
+ out.push({ text: LIST_INDENT.repeat(depth) + marker });
446
+ const content = [];
447
+ let segment = [];
448
+ const flushSegment = () => {
449
+ if (segment.length) {
450
+ content.push(...collectInline(segment, style));
451
+ segment = [];
452
+ }
453
+ };
454
+ for (const child of item.children || []) {
455
+ const name = isElement(child) ? tagName(child) : "";
456
+ if (name === "ul" || name === "ol") {
457
+ flushSegment();
458
+ content.push({ text: "\n" });
459
+ content.push(...collectList(child, style, depth + 1));
460
+ continue;
461
+ }
462
+ segment.push(child);
463
+ }
464
+ flushSegment();
465
+ out.push(...trimEdges(content));
466
+ }
467
+ return out;
468
+ };
469
+ const collectPre = (node) => {
470
+ const findCode = (current) => {
471
+ for (const child of current.children || []) {
472
+ if (!isElement(child)) {
473
+ continue;
474
+ }
475
+ if (tagName(child) === "code") {
476
+ return child;
477
+ }
478
+ const nested = findCode(child);
479
+ if (nested) {
480
+ return nested;
481
+ }
482
+ }
483
+ return null;
484
+ };
485
+ const code = findCode(node);
486
+ const match = LANGUAGE_PATTERN.exec((code && code.attribs && code.attribs.class) || "");
487
+ return {
488
+ type: "pre",
489
+ text: textContent(node).replace(/\n$/, ""),
490
+ language: match ? match[1] : "",
491
+ };
492
+ };
493
+ /**
494
+ * Таблицу Telegram не отобразит, поэтому выравниваем колонки пробелами и
495
+ * отдаём моноширинным блоком: в <pre> ширина символов одинаковая.
496
+ */
497
+ const renderTable = (table) => {
498
+ const rows = [];
499
+ let headerRows = 0;
500
+ const walk = (node, inHead) => {
501
+ for (const child of node.children || []) {
502
+ if (!isElement(child)) {
503
+ continue;
504
+ }
505
+ const name = tagName(child);
506
+ if (name === "tr") {
507
+ const cells = (child.children || []).filter((cell) => isElement(cell) && (tagName(cell) === "td" || tagName(cell) === "th"));
508
+ if (!cells.length) {
509
+ continue;
510
+ }
511
+ rows.push(cells.map((cell) => textContent(cell).replace(/\s+/g, " ").trim()));
512
+ const isHeader = inHead || cells.every((cell) => tagName(cell) === "th");
513
+ if (isHeader && headerRows === rows.length - 1) {
514
+ headerRows = rows.length;
515
+ }
516
+ continue;
517
+ }
518
+ if (name === "thead" || name === "tbody" || name === "tfoot") {
519
+ walk(child, name === "thead");
520
+ }
521
+ }
522
+ };
523
+ walk(table, false);
524
+ if (!rows.length) {
525
+ return "";
526
+ }
527
+ const columns = Math.max(...rows.map((row) => row.length));
528
+ const widths = Array.from({ length: columns }, (_, i) => Math.max(...rows.map((row) => charCount(row[i] || ""))));
529
+ const pad = (value, width) => value + " ".repeat(Math.max(0, width - charCount(value)));
530
+ const lines = [];
531
+ rows.forEach((row, i) => {
532
+ lines.push(widths.map((width, j) => pad(row[j] || "", width)).join(" | ").replace(/\s+$/, ""));
533
+ if (i + 1 === headerRows) {
534
+ lines.push(widths.map((width) => "-".repeat(width)).join("-+-"));
535
+ }
536
+ });
537
+ return lines.join("\n");
538
+ };
539
+ const paragraph = (runs) => ({
540
+ type: "paragraph",
541
+ runs: normalizeRuns(runs),
542
+ });
543
+ /**
544
+ * Цитата: вложенные блоки сплющиваются в один, потому что Telegram не
545
+ * допускает blockquote внутри blockquote; код внутри цитаты становится
546
+ * инлайновым <code>, так как <pre> в цитате тоже запрещён.
547
+ */
548
+ const collectBlockquote = (children, style, expandable) => {
549
+ const inner = new BlockCollector();
550
+ collectBlocks(children, inner, style);
551
+ inner.flush();
552
+ const runs = [];
553
+ for (const block of inner.blocks) {
554
+ if (runs.length) {
555
+ runs.push({ text: "\n" });
556
+ }
557
+ if (block.type === "pre") {
558
+ runs.push({ text: block.text, code: true });
559
+ continue;
560
+ }
561
+ runs.push(...block.runs);
562
+ }
563
+ return { type: "blockquote", runs: normalizeRuns(runs), expandable };
564
+ };
565
+ const collectBlocks = (nodes, collector, style) => {
566
+ for (const node of nodes) {
567
+ if (isText(node)) {
568
+ const data = node.data || "";
569
+ if (data.trim()) {
570
+ collector.inline([{ ...style, text: data }]);
571
+ }
572
+ else if (collector.hasPending) {
573
+ // Пробел между инлайновыми соседями значим, между блоками — нет
574
+ collector.inline([{ text: data }]);
575
+ }
576
+ continue;
577
+ }
578
+ if (!isElement(node)) {
579
+ continue;
580
+ }
581
+ const name = tagName(node);
582
+ const children = node.children || [];
583
+ const attribs = node.attribs || {};
584
+ if (SKIP_TAGS.has(name)) {
585
+ continue;
586
+ }
587
+ if (HEADING_TAGS.has(name)) {
588
+ collector.push(paragraph(collectInline(children, { ...style, bold: true })));
589
+ continue;
590
+ }
591
+ switch (name) {
592
+ case "p":
593
+ collector.push(paragraph(collectInline(children, style)));
594
+ break;
595
+ case "ul":
596
+ case "ol":
597
+ collector.push(paragraph(collectList(node, style, 0)));
598
+ break;
599
+ case "blockquote":
600
+ collector.push(collectBlockquote(children, style, "expandable" in attribs));
601
+ break;
602
+ case "pre":
603
+ collector.push(collectPre(node));
604
+ break;
605
+ case "table":
606
+ collector.push({ type: "pre", text: renderTable(node), language: "" });
607
+ break;
608
+ case "hr":
609
+ // Горизонтальной линии в Telegram нет: оставляем только границу абзацев
610
+ collector.flush();
611
+ break;
612
+ case "br":
613
+ collector.inline([{ text: "\n" }]);
614
+ break;
615
+ default:
616
+ if (BLOCK_TAGS.has(name)) {
617
+ collector.flush();
618
+ collectBlocks(children, collector, style);
619
+ collector.flush();
620
+ break;
621
+ }
622
+ collector.inline(collectInline([node], style));
623
+ }
624
+ }
625
+ };
626
+ /**
627
+ * Один отрезок в HTML. Порядок обёрток фиксирован (a > b > i > u > s > spoiler),
628
+ * code исключает остальные стили: Telegram не допускает другие сущности
629
+ * внутри code и code внутри других сущностей.
630
+ */
631
+ const runToHtml = (run) => {
632
+ const text = escapeHtml(run.text);
633
+ if (run.code) {
634
+ return `<code>${text}</code>`;
635
+ }
636
+ if (!run.text.trim()) {
637
+ return text;
638
+ }
639
+ let html = text;
640
+ if (run.spoiler) {
641
+ html = `<spoiler>${html}</spoiler>`;
642
+ }
643
+ if (run.strike) {
644
+ html = `<s>${html}</s>`;
645
+ }
646
+ if (run.underline) {
647
+ html = `<u>${html}</u>`;
648
+ }
649
+ if (run.italic) {
650
+ html = `<i>${html}</i>`;
651
+ }
652
+ if (run.bold) {
653
+ html = `<b>${html}</b>`;
654
+ }
655
+ if (run.href) {
656
+ html = `<a href="${escapeAttr(run.href)}">${html}</a>`;
657
+ }
658
+ return html;
659
+ };
660
+ const blockToHtml = (block) => {
661
+ if (block.type === "pre") {
662
+ const text = escapeHtml(block.text);
663
+ if (block.language) {
664
+ return `<pre><code class="language-${escapeAttr(block.language)}">${text}</code></pre>`;
665
+ }
666
+ return `<pre>${text}</pre>`;
667
+ }
668
+ const html = block.runs.map(runToHtml).join("");
669
+ if (block.type === "blockquote") {
670
+ return block.expandable
671
+ ? `<blockquote expandable>${html}</blockquote>`
672
+ : `<blockquote>${html}</blockquote>`;
673
+ }
674
+ return html;
675
+ };
676
+ const blocksToHtml = (blocks) => blocks.map(blockToHtml).join(SEPARATOR);
677
+ // Длина блока в символах видимого текста: именно её Telegram сравнивает с
678
+ // лимитом сообщения, теги в длину не входят
679
+ const blockLength = (block) => block.type === "pre" ? block.text.length : runsLength(block.runs);
680
+ /**
681
+ * Точка разреза текста в пределах limit: предпочтительно по переносу строки,
682
+ * затем по пробелу, но не раньше половины лимита, чтобы не плодить огрызки.
683
+ */
684
+ const findBreak = (text, limit) => {
685
+ const slice = text.slice(0, limit);
686
+ const minimum = Math.floor(limit / 2);
687
+ const newline = slice.lastIndexOf("\n");
688
+ if (newline >= minimum) {
689
+ return newline + 1;
690
+ }
691
+ const space = slice.lastIndexOf(" ");
692
+ if (space >= minimum) {
693
+ return space + 1;
694
+ }
695
+ return limit;
696
+ };
697
+ const splitRuns = (runs, limit) => {
698
+ const pieces = [];
699
+ let current = [];
700
+ let length = 0;
701
+ const queue = [...runs];
702
+ while (queue.length) {
703
+ const run = queue.shift();
704
+ const room = limit - length;
705
+ if (run.text.length <= room) {
706
+ current.push(run);
707
+ length += run.text.length;
708
+ continue;
709
+ }
710
+ // Места почти не осталось: не режем отрезок ради пары символов, а
711
+ // начинаем новый кусок целиком с него
712
+ if (current.length && room < Math.floor(limit / 4)) {
713
+ pieces.push(current);
714
+ current = [];
715
+ length = 0;
716
+ queue.unshift(run);
717
+ continue;
718
+ }
719
+ const cut = findBreak(run.text, room);
720
+ current.push({ ...run, text: run.text.slice(0, cut) });
721
+ pieces.push(current);
722
+ current = [];
723
+ length = 0;
724
+ queue.unshift({ ...run, text: run.text.slice(cut) });
725
+ }
726
+ if (current.length) {
727
+ pieces.push(current);
728
+ }
729
+ return pieces;
730
+ };
731
+ const splitBlock = (block, limit) => {
732
+ if (blockLength(block) <= limit) {
733
+ return [block];
734
+ }
735
+ if (block.type === "pre") {
736
+ const pieces = [];
737
+ let rest = block.text;
738
+ while (rest.length > limit) {
739
+ const cut = findBreak(rest, limit);
740
+ pieces.push({ ...block, text: rest.slice(0, cut).replace(/\n$/, "") });
741
+ rest = rest.slice(cut);
742
+ }
743
+ if (rest.trim()) {
744
+ pieces.push({ ...block, text: rest });
745
+ }
746
+ return pieces;
747
+ }
748
+ return splitRuns(block.runs, limit)
749
+ .map((runs) => ({ ...block, runs: normalizeRuns(runs) }))
750
+ .filter((piece) => !isEmptyBlock(piece));
751
+ };
752
+ /**
753
+ * Раскладывает блоки по сообщениям не длиннее limit символов видимого текста.
754
+ * Блок, не влезающий целиком, режется по отрезкам, так что ни один тег не
755
+ * оказывается разорванным между сообщениями.
756
+ */
757
+ const splitBlocks = (blocks, limit) => {
758
+ const safeLimit = Math.max(1, Math.floor(limit));
759
+ const chunks = [];
760
+ let current = [];
761
+ let length = 0;
762
+ for (const block of blocks) {
763
+ for (const piece of splitBlock(block, safeLimit)) {
764
+ const size = blockLength(piece);
765
+ if (current.length && length + SEPARATOR.length + size > safeLimit) {
766
+ chunks.push(current);
767
+ current = [];
768
+ length = 0;
769
+ }
770
+ current.push(piece);
771
+ length += (current.length > 1 ? SEPARATOR.length : 0) + size;
772
+ }
773
+ }
774
+ if (current.length) {
775
+ chunks.push(current);
776
+ }
777
+ return chunks;
778
+ };
779
+ class MarkdownService {
780
+ constructor() {
781
+ this.loggerService = inject(TYPES.loggerService);
782
+ /**
783
+ * Markdown в «сырой» HTML от marked, ещё со всеми тегами (p, ul, h1...).
784
+ */
785
+ this.renderHtml = async (markdown) => {
786
+ const marked = await getMarked();
787
+ return String(await marked.parse(markdown || ""));
788
+ };
789
+ /**
790
+ * Любой HTML в блоки: остаётся только то, что Telegram умеет показать.
791
+ */
792
+ this.htmlToBlocks = async (html) => {
793
+ const parseDocument = await getParseDocument();
794
+ const document = parseDocument(html || "");
795
+ const collector = new BlockCollector();
796
+ collectBlocks(document.children, collector, {});
797
+ collector.flush();
798
+ return collector.blocks;
799
+ };
800
+ this.markdownToBlocks = async (markdown) => {
801
+ return await this.htmlToBlocks(await this.renderHtml(markdown));
802
+ };
803
+ this.blocksToHtml = (blocks) => {
804
+ return blocksToHtml(blocks);
805
+ };
806
+ /**
807
+ * Markdown в HTML только с тегами, которые принимает Telegram
808
+ * (b, i, u, s, spoiler, code, pre, blockquote, a).
809
+ */
810
+ this.markdownToHtml = async (markdown) => {
811
+ this.loggerService.log("markdownService markdownToHtml", {
812
+ length: (markdown || "").length,
813
+ });
814
+ return blocksToHtml(await this.markdownToBlocks(markdown));
815
+ };
816
+ /**
817
+ * Markdown в список HTML-сообщений, каждое не длиннее limit символов
818
+ * видимого текста. Пустой Markdown даёт пустой список.
819
+ */
820
+ this.markdownToChunks = async (markdown, limit) => {
821
+ this.loggerService.log("markdownService markdownToChunks", {
822
+ length: (markdown || "").length,
823
+ limit,
824
+ });
825
+ const blocks = await this.markdownToBlocks(markdown);
826
+ return splitBlocks(blocks, limit)
827
+ .map(blocksToHtml)
828
+ .filter((chunk) => chunk.trim());
829
+ };
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Перенаправляет внутренние логи gramjs (TelegramClient) в lib.loggerService
835
+ * вместо stdout. По умолчанию LoggerService содержит noop-логгер, так что
836
+ * клиент молчит, пока потребитель не вызовет setLogger.
837
+ *
838
+ * Модуль telegram/extensions/Logger не имеет exports-карты, в ESM он
839
+ * резолвится только по полному пути с расширением, поэтому грузим его
840
+ * лениво через await import.
841
+ *
842
+ * Вместо class extends — Reflect.construct с переопределением log на
843
+ * инстансе: анонимный класс с приватными полями Logger не попадает в
844
+ * публичный тип фабрики, иначе declaration emit падает с TS4094.
845
+ */
846
+ const createTelegramLogger = functoolsKit.singleshot(async () => {
847
+ const { Logger, LogLevel } = await import('telegram/extensions/Logger.js');
848
+ const logger = Reflect.construct(Logger, [LogLevel.INFO]);
849
+ logger.log = (level, message, _color) => {
850
+ if (level === LogLevel.ERROR || level === LogLevel.WARN) {
851
+ lib.loggerService.warn(`telegram ${level}`, message);
852
+ return;
853
+ }
854
+ if (level === LogLevel.DEBUG) {
855
+ lib.loggerService.debug(`telegram ${level}`, message);
856
+ return;
857
+ }
858
+ lib.loggerService.info(`telegram ${level}`, message);
859
+ };
860
+ return logger;
861
+ });
862
+
863
+ const getTelegram = functoolsKit.singleshot(async () => {
864
+ try {
865
+ // Ленивый импорт: у gramjs нет exports-карты, подпути в ESM резолвятся
866
+ // только по полному пути с расширением
867
+ const { TelegramClient } = await import('telegram');
868
+ const { StringSession } = await import('telegram/sessions/index.js');
869
+ lib.loggerService.log("getTelegram connect");
870
+ const { CC_TELEGRAM_API_ID, CC_TELEGRAM_API_HASH } = getConfig();
871
+ const session = await promises.readFile("./session.txt", "utf-8");
872
+ const stringSession = new StringSession(session);
873
+ const client = new TelegramClient(stringSession, CC_TELEGRAM_API_ID, CC_TELEGRAM_API_HASH, {
874
+ connectionRetries: 5,
875
+ systemVersion: "Windows 10",
876
+ deviceModel: "Desktop",
877
+ appVersion: "1.0.0",
878
+ baseLogger: await createTelegramLogger(),
879
+ });
880
+ {
881
+ await client.connect();
882
+ await client.getMe();
883
+ }
884
+ return client;
885
+ }
886
+ catch (error) {
887
+ lib.loggerService.log(`getTelegram failed: no session found. Please run 'require("telegram-writer").signIn()' to create a session.`, error);
888
+ throw error;
889
+ }
890
+ });
891
+
892
+ // Длинная сторона фото: 2560px — самый крупный размер, который Telegram
893
+ // хранит для фото сам; всё, что больше, сервер ужмёт, трафик уйдёт зря.
894
+ // Заодно гарантируется лимит фото «ширина + высота <= 10000»
895
+ const PHOTO_MAX_SIDE = 2560;
896
+ // Качество JPEG. Сервер Telegram пережимает фото повторно, выше 85 разницы
897
+ // на выходе нет, а вес растёт
898
+ const PHOTO_JPEG_QUALITY = 85;
899
+ // Telegram принимает не больше 10 медиа в одном альбоме
900
+ const ALBUM_MAX_FILES = 10;
901
+ // Ограничение одновременной обработки фото, чтобы не получить OOM на слабом железе
902
+ const MAX_EXEC = 5;
903
+ // Даем задержку чтобы кластер пришел в норму
904
+ const EXEC_DELAY = 100;
905
+ // Base64 (в том виде, как его отдаёт telegram-reader) либо data-URL либо байты
906
+ const toBuffer = (photo) => {
907
+ if (Buffer.isBuffer(photo)) {
908
+ return photo;
909
+ }
910
+ const base64 = photo.replace(/^data:[^,]*;base64,/, "").replace(/\s+/g, "");
911
+ return Buffer.from(base64, "base64");
912
+ };
913
+ const chunkArray = (items, size) => {
914
+ const out = [];
915
+ for (let i = 0; i < items.length; i += size) {
916
+ out.push(items.slice(i, i + size));
917
+ }
918
+ return out;
919
+ };
920
+ const toWriterMessage = (chat, message) => ({
921
+ id: message.id,
922
+ chat,
923
+ date: new Date(message.date * 1000),
924
+ });
925
+ /**
926
+ * Приводит фото к виду, который Telegram точно примет как фото: поворот по
927
+ * EXIF, длинная сторона не больше PHOTO_MAX_SIDE, JPEG.
928
+ *
929
+ * Расширение .jpg в имени CustomFile — единственный признак, по которому
930
+ * gramjs отправит буфер как фото, а не как документ. Если sharp не смог
931
+ * прочитать данные, отправляем как есть: пусть сервер решит, фото это или нет.
932
+ *
933
+ * @param photo - base64, data-URL или Buffer с изображением
934
+ * @param index - Порядковый номер в посте, идёт в имя файла
935
+ * @returns CustomFile для client.sendFile
936
+ */
937
+ const PREPARE_PHOTO_FN = functoolsKit.execpool(async (self, photo, index) => {
938
+ // Ленивый импорт: у gramjs нет exports-карты, подпути в ESM резолвятся
939
+ // только по полному пути с расширением
940
+ const { CustomFile } = await import('telegram/client/uploads.js');
941
+ const source = toBuffer(photo);
942
+ let data = source;
943
+ try {
944
+ data = await sharp(source)
945
+ .rotate()
946
+ .resize({
947
+ width: PHOTO_MAX_SIDE,
948
+ height: PHOTO_MAX_SIDE,
949
+ fit: "inside",
950
+ withoutEnlargement: true,
951
+ })
952
+ .jpeg({ quality: PHOTO_JPEG_QUALITY })
953
+ .toBuffer();
954
+ }
955
+ catch (error) {
956
+ self.loggerService.warn(`writerService photo normalize failed index=${index}, using original`, error);
957
+ }
958
+ return new CustomFile(`photo_${index + 1}.jpg`, data.length, "", data);
959
+ }, {
960
+ maxExec: MAX_EXEC,
961
+ delay: EXEC_DELAY,
962
+ });
963
+ class WriterService {
964
+ constructor() {
965
+ this.loggerService = inject(TYPES.loggerService);
966
+ this.markdownService = inject(TYPES.markdownService);
967
+ this.sendMessage = async (dto) => {
968
+ this.loggerService.log("writerService sendMessage", {
969
+ chat: dto.chat,
970
+ length: (dto.content || "").length,
971
+ photos: (dto.photos || []).length,
972
+ });
973
+ const client = await getTelegram();
974
+ const { CC_TELEGRAM_MESSAGE_LIMIT, CC_TELEGRAM_CAPTION_LIMIT } = getConfig();
975
+ const photos = dto.photos || [];
976
+ const silent = !!dto.silent;
977
+ const linkPreview = !!dto.linkPreview;
978
+ const sent = [];
979
+ const sendText = async (limit) => {
980
+ const chunks = await this.markdownService.markdownToChunks(dto.content, limit);
981
+ for (const chunk of chunks) {
982
+ const message = await client.sendMessage(dto.chat, {
983
+ message: chunk,
984
+ parseMode: "html",
985
+ silent,
986
+ linkPreview,
987
+ // Отвечаем только первым сообщением: остальные идут продолжением
988
+ replyTo: sent.length ? undefined : dto.replyTo,
989
+ });
990
+ sent.push(message);
991
+ }
992
+ };
993
+ if (!photos.length) {
994
+ await sendText(CC_TELEGRAM_MESSAGE_LIMIT);
995
+ if (!sent.length) {
996
+ this.loggerService.warn("writerService sendMessage nothing to send: empty content and no photos");
997
+ }
998
+ return sent.map((message) => toWriterMessage(dto.chat, message));
999
+ }
1000
+ const files = await Promise.all(photos.map((photo, index) => PREPARE_PHOTO_FN(this, photo, index)));
1001
+ // Текст идёт подписью к первому альбому, если влезает в лимит подписи.
1002
+ // Иначе сначала уходят фото без подписи, затем текст отдельными сообщениями
1003
+ const captionChunks = await this.markdownService.markdownToChunks(dto.content, CC_TELEGRAM_CAPTION_LIMIT);
1004
+ const fitsCaption = captionChunks.length <= 1;
1005
+ const albums = chunkArray(files, ALBUM_MAX_FILES);
1006
+ for (const [albumIndex, album] of albums.entries()) {
1007
+ const caption = albumIndex === 0 && fitsCaption ? captionChunks[0] || "" : "";
1008
+ const result = (await client.sendFile(dto.chat, {
1009
+ file: album.length === 1 ? album[0] : album,
1010
+ caption: album.length === 1 ? caption : [caption, ...album.slice(1).map(() => "")],
1011
+ parseMode: "html",
1012
+ silent,
1013
+ replyTo: albumIndex === 0 ? dto.replyTo : undefined,
1014
+ }));
1015
+ sent.push(...(Array.isArray(result) ? result : [result]).filter(Boolean));
1016
+ }
1017
+ if (!fitsCaption) {
1018
+ await sendText(CC_TELEGRAM_MESSAGE_LIMIT);
1019
+ }
1020
+ return sent.map((message) => toWriterMessage(dto.chat, message));
1021
+ };
1022
+ }
1023
+ }
1024
+
1025
+ class TelegramGlobalService {
1026
+ constructor() {
1027
+ this.loggerService = inject(TYPES.loggerService);
1028
+ this.authService = inject(TYPES.authService);
1029
+ this.writerService = inject(TYPES.writerService);
1030
+ this.signIn = async () => {
1031
+ this.loggerService.log("telegramGlobalService signIn");
1032
+ await this.authService.signIn();
1033
+ };
1034
+ this.sendMessage = async (dto) => {
1035
+ this.loggerService.log("telegramGlobalService sendMessage", {
1036
+ chat: dto.chat,
1037
+ length: (dto.content || "").length,
1038
+ photos: (dto.photos || []).length,
1039
+ });
1040
+ return await this.writerService.sendMessage(dto);
1041
+ };
1042
+ }
1043
+ }
1044
+
1045
+ {
1046
+ provide(TYPES.authService, () => new AuthService());
1047
+ provide(TYPES.loggerService, () => new LoggerService());
1048
+ provide(TYPES.markdownService, () => new MarkdownService());
1049
+ provide(TYPES.writerService, () => new WriterService());
1050
+ }
1051
+ {
1052
+ provide(TYPES.telegramGlobalService, () => new TelegramGlobalService());
1053
+ }
1054
+
1055
+ const baseServices = {
1056
+ authService: inject(TYPES.authService),
1057
+ loggerService: inject(TYPES.loggerService),
1058
+ markdownService: inject(TYPES.markdownService),
1059
+ writerService: inject(TYPES.writerService),
1060
+ };
1061
+ const globalServices = {
1062
+ telegramGlobalService: inject(TYPES.telegramGlobalService),
1063
+ };
1064
+ const lib = {
1065
+ ...baseServices,
1066
+ ...globalServices,
1067
+ };
1068
+ init();
1069
+
1070
+ /**
1071
+ * Signs the client in to Telegram, establishing an authorized MTProto session.
1072
+ *
1073
+ * Delegates to the auth service, which drives the interactive authentication
1074
+ * flow (QR code, then the 2FA password if enabled) and persists the resulting
1075
+ * session to `./session.txt` so that subsequent {@link sendMessage} calls can
1076
+ * reuse it without re-authenticating.
1077
+ *
1078
+ * Must be called once before {@link sendMessage} when no saved session exists.
1079
+ *
1080
+ * @returns Promise that resolves once the session is authorized
1081
+ */
1082
+ async function signIn() {
1083
+ lib.loggerService.log("functions.signIn");
1084
+ return await lib.telegramGlobalService.signIn();
1085
+ }
1086
+
1087
+ /**
1088
+ * Publishes a Markdown post, optionally with photos, to a Telegram chat or channel.
1089
+ *
1090
+ * The Markdown is rendered to HTML, then reduced to the tags Telegram accepts
1091
+ * (`b`, `i`, `u`, `s`, `spoiler`, `code`, `pre`, `blockquote`, `a`); block
1092
+ * structure that Telegram cannot display is flattened into text (headings become
1093
+ * bold lines, lists become bullet lines, tables become monospace blocks, images
1094
+ * become links). The conversion happens inside; raw Markdown never reaches Telegram.
1095
+ *
1096
+ * Delivery rules:
1097
+ * - Without photos the text is sent as one or more messages, each within the
1098
+ * message limit (`CC_TELEGRAM_MESSAGE_LIMIT`, 4096 by default). A long post is
1099
+ * split between paragraphs, or inside a paragraph at a line/word boundary;
1100
+ * formatting never breaks across messages.
1101
+ * - With photos the photos are sent first as albums of up to 10 items. The text
1102
+ * becomes the caption of the first album when it fits the caption limit
1103
+ * (`CC_TELEGRAM_CAPTION_LIMIT`, 1024 by default); otherwise the albums go
1104
+ * without a caption and the text follows as separate messages.
1105
+ * - Photos are accepted as base64 strings (the format `telegram-reader` returns),
1106
+ * data-URLs or Buffers. Each is rotated by EXIF, downsized to at most 2560px on
1107
+ * the long side and re-encoded as JPEG so Telegram treats it as a photo.
1108
+ *
1109
+ * @param dto - Send parameters
1110
+ * @param dto.chat - Chat username (`"channel"`, `"@channel"`), numeric id as a string, or `"me"`
1111
+ * @param dto.content - Post body in Markdown (GFM; single newlines are line breaks)
1112
+ * @param dto.photos - Photos to attach: base64, data-URL or Buffer each
1113
+ * @param dto.silent - Send without notifying the chat members
1114
+ * @param dto.replyTo - Id of the message to reply to (applied to the first sent message)
1115
+ * @param dto.linkPreview - Show a link preview for the first URL (defaults to `false`)
1116
+ * @returns Promise resolving to the sent messages in send order
1117
+ */
1118
+ async function sendMessage(dto) {
1119
+ lib.loggerService.log("functions.sendMessage", {
1120
+ chat: dto.chat,
1121
+ length: (dto.content || "").length,
1122
+ photos: (dto.photos || []).length,
1123
+ });
1124
+ return await lib.telegramGlobalService.sendMessage(dto);
1125
+ }
1126
+
1127
+ exports.getConfig = getConfig;
1128
+ exports.getTelegram = getTelegram;
1129
+ exports.lib = lib;
1130
+ exports.sendMessage = sendMessage;
1131
+ exports.setConfig = setConfig;
1132
+ exports.signIn = signIn;