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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/build/index.cjs +1132 -0
- package/build/index.mjs +1125 -0
- package/example/index.mjs +40 -0
- package/package.json +65 -4
- package/rollup.config.mjs +37 -0
- package/scripts/fix.ts +45 -0
- package/src/config/logger.ts +35 -0
- package/src/config/params.ts +40 -0
- package/src/config/telegram.ts +33 -0
- package/src/functions/send_message.ts +48 -0
- package/src/functions/sign_in.ts +18 -0
- package/src/index.ts +11 -0
- package/src/lib/core/di.ts +3 -0
- package/src/lib/core/provide.ts +20 -0
- package/src/lib/core/types.ts +17 -0
- package/src/lib/index.ts +30 -0
- package/src/lib/services/base/AuthService.ts +74 -0
- package/src/lib/services/base/LoggerService.ts +63 -0
- package/src/lib/services/base/MarkdownService.ts +786 -0
- package/src/lib/services/base/WriterService.ts +178 -0
- package/src/lib/services/global/TelegramGlobalService.ts +35 -0
- package/src/model/TextBlock.model.ts +43 -0
- package/src/model/WriterMessage.model.ts +8 -0
- package/tsconfig.json +30 -0
- package/types.d.ts +206 -0
package/build/index.cjs
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
181
|
+
const escapeAttr = (text) => escapeHtml(text).replace(/"/g, """);
|
|
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;
|