@grinev/opencode-telegram-bot 0.15.0 → 0.16.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/.env.example +16 -0
- package/README.md +22 -1
- package/dist/app/start-bot-app.js +82 -7
- package/dist/bot/assistant-run-state.js +60 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +10 -0
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/open.js +314 -0
- package/dist/bot/commands/projects.js +5 -38
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/handlers/inline-menu.js +9 -1
- package/dist/bot/handlers/prompt.js +11 -0
- package/dist/bot/index.js +187 -100
- package/dist/bot/streaming/response-streamer.js +26 -17
- package/dist/bot/utils/assistant-rendering.js +117 -0
- package/dist/bot/utils/assistant-run-footer.js +9 -0
- package/dist/bot/utils/browser-roots.js +140 -0
- package/dist/bot/utils/file-tree.js +92 -0
- package/dist/bot/utils/finalize-assistant-response.js +18 -24
- package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
- package/dist/bot/utils/switch-project.js +48 -0
- package/dist/bot/utils/telegram-text.js +95 -1
- package/dist/cli/args.js +36 -7
- package/dist/cli.js +133 -15
- package/dist/config.js +4 -0
- package/dist/i18n/de.js +15 -10
- package/dist/i18n/en.js +15 -10
- package/dist/i18n/es.js +15 -10
- package/dist/i18n/fr.js +15 -10
- package/dist/i18n/ru.js +15 -10
- package/dist/i18n/zh.js +15 -10
- package/dist/index.js +2 -0
- package/dist/project/manager.js +2 -1
- package/dist/scheduled-task/runtime.js +8 -0
- package/dist/service/manager.js +244 -0
- package/dist/service/runtime.js +19 -0
- package/dist/service/types.js +1 -0
- package/dist/summary/aggregator.js +17 -1
- package/dist/summary/formatter.js +2 -88
- package/dist/telegram/render/block-fallback.js +28 -0
- package/dist/telegram/render/block-parser.js +295 -0
- package/dist/telegram/render/block-renderer.js +457 -0
- package/dist/telegram/render/chunker.js +281 -0
- package/dist/telegram/render/inline-renderer.js +128 -0
- package/dist/telegram/render/markdown-normalizer.js +94 -0
- package/dist/telegram/render/pipeline.js +9 -0
- package/dist/telegram/render/types.js +1 -0
- package/dist/telegram/render/validator.js +160 -0
- package/dist/utils/logger.js +200 -73
- package/package.json +6 -2
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
import { validateTelegramEntities } from "./validator.js";
|
|
3
|
+
const DEFAULT_MAX_PART_LENGTH = 4096;
|
|
4
|
+
const DEFAULT_BLOCK_SEPARATOR = "\n\n";
|
|
5
|
+
const ENTITY_TYPE_PRIORITY = {
|
|
6
|
+
bold: 1,
|
|
7
|
+
italic: 2,
|
|
8
|
+
underline: 3,
|
|
9
|
+
strikethrough: 4,
|
|
10
|
+
spoiler: 5,
|
|
11
|
+
code: 6,
|
|
12
|
+
pre: 7,
|
|
13
|
+
text_link: 8,
|
|
14
|
+
mention: 100,
|
|
15
|
+
hashtag: 101,
|
|
16
|
+
cashtag: 102,
|
|
17
|
+
bot_command: 103,
|
|
18
|
+
url: 104,
|
|
19
|
+
email: 105,
|
|
20
|
+
phone_number: 106,
|
|
21
|
+
blockquote: 107,
|
|
22
|
+
expandable_blockquote: 108,
|
|
23
|
+
text_mention: 109,
|
|
24
|
+
custom_emoji: 110,
|
|
25
|
+
};
|
|
26
|
+
function isHighSurrogate(codeUnit) {
|
|
27
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
28
|
+
}
|
|
29
|
+
function isLowSurrogate(codeUnit) {
|
|
30
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
31
|
+
}
|
|
32
|
+
function isSafeUtf16Boundary(text, index) {
|
|
33
|
+
if (index <= 0 || index >= text.length) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return !(isHighSurrogate(text.charCodeAt(index - 1)) && isLowSurrogate(text.charCodeAt(index)));
|
|
37
|
+
}
|
|
38
|
+
function isEntityBoundary(entities, index) {
|
|
39
|
+
if (!entities?.length) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return !entities.some((entity) => entity.offset < index && index < entity.offset + entity.length);
|
|
43
|
+
}
|
|
44
|
+
function compareEntities(left, right) {
|
|
45
|
+
if (left.offset !== right.offset) {
|
|
46
|
+
return left.offset - right.offset;
|
|
47
|
+
}
|
|
48
|
+
if (left.length !== right.length) {
|
|
49
|
+
return right.length - left.length;
|
|
50
|
+
}
|
|
51
|
+
return ENTITY_TYPE_PRIORITY[left.type] - ENTITY_TYPE_PRIORITY[right.type];
|
|
52
|
+
}
|
|
53
|
+
function sortEntities(entities) {
|
|
54
|
+
return [...entities].sort(compareEntities);
|
|
55
|
+
}
|
|
56
|
+
function normalizeOptions(options) {
|
|
57
|
+
return {
|
|
58
|
+
maxPartLength: Math.max(2, Math.floor(options?.maxPartLength ?? DEFAULT_MAX_PART_LENGTH)),
|
|
59
|
+
blockSeparator: options?.blockSeparator ?? DEFAULT_BLOCK_SEPARATOR,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function createRenderedPart(text, fallbackText, entities) {
|
|
63
|
+
const normalizedEntities = entities?.length ? sortEntities(entities) : undefined;
|
|
64
|
+
if (normalizedEntities) {
|
|
65
|
+
const validation = validateTelegramEntities(text, normalizedEntities);
|
|
66
|
+
if (!validation.ok) {
|
|
67
|
+
throw new Error(validation.issues.map((issue) => issue.message).join("; "));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
text,
|
|
72
|
+
entities: normalizedEntities,
|
|
73
|
+
fallbackText,
|
|
74
|
+
source: normalizedEntities?.length ? "entities" : "plain",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function clonePart(part) {
|
|
78
|
+
return {
|
|
79
|
+
text: part.text,
|
|
80
|
+
entities: part.entities ? [...part.entities] : undefined,
|
|
81
|
+
fallbackText: part.fallbackText,
|
|
82
|
+
source: part.source,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function isWhitespaceBoundary(text, index) {
|
|
86
|
+
return index > 0 && /\s/.test(text[index - 1]);
|
|
87
|
+
}
|
|
88
|
+
function findSplitBoundary(text, start, maxLength, entities) {
|
|
89
|
+
const hardEnd = Math.min(text.length, start + maxLength);
|
|
90
|
+
if (hardEnd >= text.length) {
|
|
91
|
+
return text.length;
|
|
92
|
+
}
|
|
93
|
+
let whitespaceBoundary = null;
|
|
94
|
+
let fallbackBoundary = null;
|
|
95
|
+
for (let index = hardEnd; index > start; index--) {
|
|
96
|
+
if (!isSafeUtf16Boundary(text, index) || !isEntityBoundary(entities, index)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (text[index - 1] === "\n") {
|
|
100
|
+
return index;
|
|
101
|
+
}
|
|
102
|
+
if (whitespaceBoundary === null && isWhitespaceBoundary(text, index)) {
|
|
103
|
+
whitespaceBoundary = index;
|
|
104
|
+
}
|
|
105
|
+
if (fallbackBoundary === null) {
|
|
106
|
+
fallbackBoundary = index;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return whitespaceBoundary ?? fallbackBoundary;
|
|
110
|
+
}
|
|
111
|
+
function rebaseSliceEntities(entities, start, end) {
|
|
112
|
+
if (!entities?.length) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return entities
|
|
116
|
+
.filter((entity) => entity.offset >= start && entity.offset + entity.length <= end)
|
|
117
|
+
.map((entity) => ({
|
|
118
|
+
...entity,
|
|
119
|
+
offset: entity.offset - start,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
function splitPlainText(text, maxLength) {
|
|
123
|
+
if (!text) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let start = 0;
|
|
128
|
+
while (start < text.length) {
|
|
129
|
+
const end = findSplitBoundary(text, start, maxLength);
|
|
130
|
+
if (!end || end <= start) {
|
|
131
|
+
throw new Error("Unable to split plain text on a safe UTF-16 boundary");
|
|
132
|
+
}
|
|
133
|
+
chunks.push(text.slice(start, end));
|
|
134
|
+
start = end;
|
|
135
|
+
}
|
|
136
|
+
return chunks;
|
|
137
|
+
}
|
|
138
|
+
function isFullRangePreEntity(block) {
|
|
139
|
+
if (block.entities?.length !== 1) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const [entity] = block.entities;
|
|
143
|
+
if (entity.type !== "pre" || entity.offset !== 0 || entity.length !== block.text.length) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return entity;
|
|
147
|
+
}
|
|
148
|
+
function splitPreformattedBlock(block, maxLength, preEntity) {
|
|
149
|
+
const parts = [];
|
|
150
|
+
let start = 0;
|
|
151
|
+
while (start < block.text.length) {
|
|
152
|
+
const end = findSplitBoundary(block.text, start, maxLength);
|
|
153
|
+
if (!end || end <= start) {
|
|
154
|
+
throw new Error(`Unable to split preformatted ${block.blockType} block`);
|
|
155
|
+
}
|
|
156
|
+
const text = block.text.slice(start, end);
|
|
157
|
+
parts.push(createRenderedPart(text, text, [
|
|
158
|
+
{
|
|
159
|
+
type: "pre",
|
|
160
|
+
offset: 0,
|
|
161
|
+
length: text.length,
|
|
162
|
+
...(preEntity.language ? { language: preEntity.language } : {}),
|
|
163
|
+
},
|
|
164
|
+
]));
|
|
165
|
+
start = end;
|
|
166
|
+
}
|
|
167
|
+
logger.debug("[TelegramRender] Preformatted block chunked", {
|
|
168
|
+
blockType: block.blockType,
|
|
169
|
+
textLength: block.text.length,
|
|
170
|
+
maxLength,
|
|
171
|
+
partCount: parts.length,
|
|
172
|
+
});
|
|
173
|
+
return parts;
|
|
174
|
+
}
|
|
175
|
+
function splitRichBlock(block, maxLength) {
|
|
176
|
+
if (!block.entities?.length) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const parts = [];
|
|
180
|
+
let start = 0;
|
|
181
|
+
while (start < block.text.length) {
|
|
182
|
+
const end = findSplitBoundary(block.text, start, maxLength, block.entities);
|
|
183
|
+
if (!end || end <= start) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const entities = rebaseSliceEntities(block.entities, start, end);
|
|
187
|
+
const text = block.text.slice(start, end);
|
|
188
|
+
parts.push(createRenderedPart(text, text, entities));
|
|
189
|
+
start = end;
|
|
190
|
+
}
|
|
191
|
+
if (parts.length > 1) {
|
|
192
|
+
logger.debug("[TelegramRender] Rich block chunked", {
|
|
193
|
+
blockType: block.blockType,
|
|
194
|
+
textLength: block.text.length,
|
|
195
|
+
entityCount: block.entities.length,
|
|
196
|
+
maxLength,
|
|
197
|
+
partCount: parts.length,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return parts;
|
|
201
|
+
}
|
|
202
|
+
function splitBlockToParts(block, maxLength) {
|
|
203
|
+
if (!block.text) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
if (block.text.length <= maxLength) {
|
|
207
|
+
return [createRenderedPart(block.text, block.fallbackText, block.entities)];
|
|
208
|
+
}
|
|
209
|
+
const preEntity = isFullRangePreEntity(block);
|
|
210
|
+
if (preEntity) {
|
|
211
|
+
return splitPreformattedBlock(block, maxLength, preEntity);
|
|
212
|
+
}
|
|
213
|
+
if (block.entities?.length) {
|
|
214
|
+
const richParts = splitRichBlock(block, maxLength);
|
|
215
|
+
if (richParts) {
|
|
216
|
+
return richParts;
|
|
217
|
+
}
|
|
218
|
+
logger.debug("[TelegramRender] Rich block downgraded to plain during chunking", {
|
|
219
|
+
blockType: block.blockType,
|
|
220
|
+
textLength: block.text.length,
|
|
221
|
+
entityCount: block.entities.length,
|
|
222
|
+
maxLength,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return splitPlainText(block.fallbackText, maxLength).map((text) => createRenderedPart(text, text));
|
|
226
|
+
}
|
|
227
|
+
function createBuilder() {
|
|
228
|
+
return {
|
|
229
|
+
text: "",
|
|
230
|
+
fallbackText: "",
|
|
231
|
+
entities: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function appendToBuilder(builder, chunk, prefix) {
|
|
235
|
+
const offset = builder.text.length + prefix.length;
|
|
236
|
+
builder.text += `${prefix}${chunk.text}`;
|
|
237
|
+
builder.fallbackText += `${prefix}${chunk.fallbackText}`;
|
|
238
|
+
if (chunk.entities?.length) {
|
|
239
|
+
builder.entities.push(...chunk.entities.map((entity) => ({
|
|
240
|
+
...entity,
|
|
241
|
+
offset: entity.offset + offset,
|
|
242
|
+
})));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function finalizeBuilder(builder) {
|
|
246
|
+
if (!builder || !builder.text) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return createRenderedPart(builder.text, builder.fallbackText, builder.entities);
|
|
250
|
+
}
|
|
251
|
+
export function chunkTelegramRenderedBlocks(blocks, options) {
|
|
252
|
+
const { maxPartLength, blockSeparator } = normalizeOptions(options);
|
|
253
|
+
const blockGroups = blocks
|
|
254
|
+
.map((block) => splitBlockToParts(block, maxPartLength))
|
|
255
|
+
.filter((group) => group.length > 0);
|
|
256
|
+
const parts = [];
|
|
257
|
+
let current = createBuilder();
|
|
258
|
+
for (const blockParts of blockGroups) {
|
|
259
|
+
for (let index = 0; index < blockParts.length; index++) {
|
|
260
|
+
const chunk = blockParts[index];
|
|
261
|
+
const needsSeparator = index === 0 && current.text.length > 0;
|
|
262
|
+
const prefix = needsSeparator ? blockSeparator : "";
|
|
263
|
+
if (current.text.length > 0 &&
|
|
264
|
+
current.text.length + prefix.length + chunk.text.length > maxPartLength) {
|
|
265
|
+
const finalized = finalizeBuilder(current);
|
|
266
|
+
if (finalized) {
|
|
267
|
+
parts.push(finalized);
|
|
268
|
+
}
|
|
269
|
+
current = createBuilder();
|
|
270
|
+
appendToBuilder(current, chunk, "");
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
appendToBuilder(current, chunk, prefix);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const finalized = finalizeBuilder(current);
|
|
277
|
+
if (finalized) {
|
|
278
|
+
parts.push(finalized);
|
|
279
|
+
}
|
|
280
|
+
return parts.map(clonePart);
|
|
281
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { validateTelegramEntities } from "./validator.js";
|
|
2
|
+
const ENTITY_TYPE_PRIORITY = {
|
|
3
|
+
bold: 1,
|
|
4
|
+
italic: 2,
|
|
5
|
+
underline: 3,
|
|
6
|
+
strikethrough: 4,
|
|
7
|
+
spoiler: 5,
|
|
8
|
+
code: 6,
|
|
9
|
+
text_link: 7,
|
|
10
|
+
mention: 100,
|
|
11
|
+
hashtag: 101,
|
|
12
|
+
cashtag: 102,
|
|
13
|
+
bot_command: 103,
|
|
14
|
+
url: 104,
|
|
15
|
+
email: 105,
|
|
16
|
+
phone_number: 106,
|
|
17
|
+
blockquote: 107,
|
|
18
|
+
expandable_blockquote: 108,
|
|
19
|
+
pre: 109,
|
|
20
|
+
text_mention: 110,
|
|
21
|
+
custom_emoji: 111,
|
|
22
|
+
};
|
|
23
|
+
function appendText(state, text) {
|
|
24
|
+
if (!text) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
state.text += text;
|
|
28
|
+
}
|
|
29
|
+
function pushEntity(state, entity) {
|
|
30
|
+
if (entity.length <= 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
state.entities.push(entity);
|
|
34
|
+
}
|
|
35
|
+
function renderIntoState(state, nodes) {
|
|
36
|
+
for (const node of nodes) {
|
|
37
|
+
switch (node.type) {
|
|
38
|
+
case "text":
|
|
39
|
+
appendText(state, node.text);
|
|
40
|
+
break;
|
|
41
|
+
case "bold": {
|
|
42
|
+
const offset = state.text.length;
|
|
43
|
+
renderIntoState(state, node.children);
|
|
44
|
+
pushEntity(state, { type: "bold", offset, length: state.text.length - offset });
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case "italic": {
|
|
48
|
+
const offset = state.text.length;
|
|
49
|
+
renderIntoState(state, node.children);
|
|
50
|
+
pushEntity(state, { type: "italic", offset, length: state.text.length - offset });
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case "strike": {
|
|
54
|
+
const offset = state.text.length;
|
|
55
|
+
renderIntoState(state, node.children);
|
|
56
|
+
pushEntity(state, {
|
|
57
|
+
type: "strikethrough",
|
|
58
|
+
offset,
|
|
59
|
+
length: state.text.length - offset,
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "underline": {
|
|
64
|
+
const offset = state.text.length;
|
|
65
|
+
renderIntoState(state, node.children);
|
|
66
|
+
pushEntity(state, { type: "underline", offset, length: state.text.length - offset });
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "spoiler": {
|
|
70
|
+
const offset = state.text.length;
|
|
71
|
+
renderIntoState(state, node.children);
|
|
72
|
+
pushEntity(state, { type: "spoiler", offset, length: state.text.length - offset });
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "code": {
|
|
76
|
+
const offset = state.text.length;
|
|
77
|
+
appendText(state, node.text);
|
|
78
|
+
pushEntity(state, { type: "code", offset, length: state.text.length - offset });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "link": {
|
|
82
|
+
const offset = state.text.length;
|
|
83
|
+
renderIntoState(state, node.text);
|
|
84
|
+
pushEntity(state, {
|
|
85
|
+
type: "text_link",
|
|
86
|
+
offset,
|
|
87
|
+
length: state.text.length - offset,
|
|
88
|
+
url: node.url,
|
|
89
|
+
});
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
default: {
|
|
93
|
+
const exhaustiveCheck = node;
|
|
94
|
+
throw new Error(`Unsupported inline node: ${JSON.stringify(exhaustiveCheck)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function compareEntities(left, right) {
|
|
100
|
+
if (left.offset !== right.offset) {
|
|
101
|
+
return left.offset - right.offset;
|
|
102
|
+
}
|
|
103
|
+
if (left.length !== right.length) {
|
|
104
|
+
return right.length - left.length;
|
|
105
|
+
}
|
|
106
|
+
return ENTITY_TYPE_PRIORITY[left.type] - ENTITY_TYPE_PRIORITY[right.type];
|
|
107
|
+
}
|
|
108
|
+
export function renderInlineNodes(nodes) {
|
|
109
|
+
const state = {
|
|
110
|
+
text: "",
|
|
111
|
+
entities: [],
|
|
112
|
+
};
|
|
113
|
+
renderIntoState(state, nodes);
|
|
114
|
+
state.entities.sort(compareEntities);
|
|
115
|
+
return {
|
|
116
|
+
text: state.text,
|
|
117
|
+
entities: state.entities,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function renderInlineNodesValidated(nodes) {
|
|
121
|
+
const rendered = renderInlineNodes(nodes);
|
|
122
|
+
const validation = validateTelegramEntities(rendered.text, rendered.entities);
|
|
123
|
+
if (!validation.ok) {
|
|
124
|
+
const summary = validation.issues.map((issue) => issue.message).join("; ");
|
|
125
|
+
throw new Error(`Invalid Telegram inline entities: ${summary}`);
|
|
126
|
+
}
|
|
127
|
+
return rendered;
|
|
128
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
function isCodeFenceLine(line) {
|
|
2
|
+
return line.trimStart().startsWith("```");
|
|
3
|
+
}
|
|
4
|
+
function isHorizontalRuleLine(line) {
|
|
5
|
+
const normalized = line.trim();
|
|
6
|
+
if (!normalized) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
return /^([-*_])(?:\s*\1){2,}$/.test(normalized);
|
|
10
|
+
}
|
|
11
|
+
function isHeadingLine(line) {
|
|
12
|
+
return /^\s{0,3}#{1,6}\s+\S/.test(line);
|
|
13
|
+
}
|
|
14
|
+
function normalizeHeadingLine(line) {
|
|
15
|
+
const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
|
|
16
|
+
if (!match) {
|
|
17
|
+
return line;
|
|
18
|
+
}
|
|
19
|
+
return `**${match[1]}**`;
|
|
20
|
+
}
|
|
21
|
+
function normalizeChecklistLine(line) {
|
|
22
|
+
const match = line.match(/^(\s*)(?:[-+*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/);
|
|
23
|
+
if (!match) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const marker = match[2].toLowerCase() === "x" ? "✅" : "🔲";
|
|
27
|
+
return `${match[1]}${marker} ${match[3]}`;
|
|
28
|
+
}
|
|
29
|
+
function normalizeMarkdown(text, options) {
|
|
30
|
+
const lines = text.split("\n");
|
|
31
|
+
const output = [];
|
|
32
|
+
let inCodeFence = false;
|
|
33
|
+
let inQuote = false;
|
|
34
|
+
for (let index = 0; index < lines.length; index++) {
|
|
35
|
+
const line = lines[index];
|
|
36
|
+
if (isCodeFenceLine(line)) {
|
|
37
|
+
inCodeFence = !inCodeFence;
|
|
38
|
+
inQuote = false;
|
|
39
|
+
output.push(line);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (inCodeFence) {
|
|
43
|
+
output.push(line);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!line.trim()) {
|
|
47
|
+
inQuote = false;
|
|
48
|
+
output.push(line);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (isHeadingLine(line)) {
|
|
52
|
+
output.push(options.preserveBlockMarkup ? line : normalizeHeadingLine(line));
|
|
53
|
+
inQuote = false;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (isHorizontalRuleLine(line)) {
|
|
57
|
+
output.push(options.preserveBlockMarkup ? line : "──────────");
|
|
58
|
+
inQuote = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const trimmedLeft = line.trimStart();
|
|
62
|
+
if (trimmedLeft.startsWith(">")) {
|
|
63
|
+
inQuote = true;
|
|
64
|
+
const quoteContent = trimmedLeft.replace(/^>\s?/, "");
|
|
65
|
+
const normalizedChecklistInQuote = normalizeChecklistLine(quoteContent);
|
|
66
|
+
output.push(normalizedChecklistInQuote && !options.preserveBlockMarkup
|
|
67
|
+
? `> ${normalizedChecklistInQuote.trimStart()}`
|
|
68
|
+
: `> ${quoteContent.trimStart()}`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const normalizedChecklist = normalizeChecklistLine(line);
|
|
72
|
+
if (normalizedChecklist) {
|
|
73
|
+
if (options.preserveBlockMarkup) {
|
|
74
|
+
output.push(inQuote ? `> ${trimmedLeft}` : line);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
output.push(inQuote ? `> ${normalizedChecklist.trimStart()}` : normalizedChecklist);
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (inQuote) {
|
|
82
|
+
output.push(`> ${trimmedLeft}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
output.push(line);
|
|
86
|
+
}
|
|
87
|
+
return output.join("\n");
|
|
88
|
+
}
|
|
89
|
+
export function normalizeMarkdownForTelegramRendering(text) {
|
|
90
|
+
return normalizeMarkdown(text, { preserveBlockMarkup: false });
|
|
91
|
+
}
|
|
92
|
+
export function normalizeMarkdownForTelegramBlockParsing(text) {
|
|
93
|
+
return normalizeMarkdown(text, { preserveBlockMarkup: true });
|
|
94
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parseTelegramBlocks } from "./block-parser.js";
|
|
2
|
+
import { renderTelegramBlockWithFallback } from "./block-fallback.js";
|
|
3
|
+
import { chunkTelegramRenderedBlocks } from "./chunker.js";
|
|
4
|
+
export function renderTelegramBlocks(markdown) {
|
|
5
|
+
return parseTelegramBlocks(markdown).map(renderTelegramBlockWithFallback);
|
|
6
|
+
}
|
|
7
|
+
export function renderTelegramParts(markdown, options) {
|
|
8
|
+
return chunkTelegramRenderedBlocks(renderTelegramBlocks(markdown), options);
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const SUPPORTED_ENTITY_TYPES = new Set([
|
|
2
|
+
"bold",
|
|
3
|
+
"italic",
|
|
4
|
+
"underline",
|
|
5
|
+
"strikethrough",
|
|
6
|
+
"spoiler",
|
|
7
|
+
"code",
|
|
8
|
+
"pre",
|
|
9
|
+
"text_link",
|
|
10
|
+
]);
|
|
11
|
+
const STYLE_ENTITY_TYPES = new Set([
|
|
12
|
+
"bold",
|
|
13
|
+
"italic",
|
|
14
|
+
"underline",
|
|
15
|
+
"strikethrough",
|
|
16
|
+
"spoiler",
|
|
17
|
+
]);
|
|
18
|
+
function getRange(entity) {
|
|
19
|
+
return {
|
|
20
|
+
start: entity.offset,
|
|
21
|
+
end: entity.offset + entity.length,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function rangesEqual(left, right) {
|
|
25
|
+
return left.offset === right.offset && left.length === right.length;
|
|
26
|
+
}
|
|
27
|
+
function rangesOverlap(left, right) {
|
|
28
|
+
const leftRange = getRange(left);
|
|
29
|
+
const rightRange = getRange(right);
|
|
30
|
+
return leftRange.start < rightRange.end && rightRange.start < leftRange.end;
|
|
31
|
+
}
|
|
32
|
+
function isFullyNested(left, right) {
|
|
33
|
+
const leftRange = getRange(left);
|
|
34
|
+
const rightRange = getRange(right);
|
|
35
|
+
return leftRange.start <= rightRange.start && leftRange.end >= rightRange.end;
|
|
36
|
+
}
|
|
37
|
+
function isPartialOverlap(left, right) {
|
|
38
|
+
if (!rangesOverlap(left, right) || rangesEqual(left, right)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return !isFullyNested(left, right) && !isFullyNested(right, left);
|
|
42
|
+
}
|
|
43
|
+
function isStyleEntity(entity) {
|
|
44
|
+
return STYLE_ENTITY_TYPES.has(entity.type);
|
|
45
|
+
}
|
|
46
|
+
function isValidLinkUrl(url) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(url);
|
|
49
|
+
return ["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function validateEntityShape(textLength, entity, entityIndex) {
|
|
56
|
+
const issues = [];
|
|
57
|
+
if (!SUPPORTED_ENTITY_TYPES.has(entity.type)) {
|
|
58
|
+
issues.push({
|
|
59
|
+
code: "unsupported_entity_type",
|
|
60
|
+
message: `Unsupported Telegram entity type: ${entity.type}`,
|
|
61
|
+
entityIndex,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isInteger(entity.offset) || entity.offset < 0) {
|
|
65
|
+
issues.push({
|
|
66
|
+
code: "invalid_offset",
|
|
67
|
+
message: `Entity offset must be a non-negative integer, got ${entity.offset}`,
|
|
68
|
+
entityIndex,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isInteger(entity.length) || entity.length <= 0) {
|
|
72
|
+
issues.push({
|
|
73
|
+
code: "invalid_length",
|
|
74
|
+
message: `Entity length must be a positive integer, got ${entity.length}`,
|
|
75
|
+
entityIndex,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (entity.offset + entity.length > textLength) {
|
|
79
|
+
issues.push({
|
|
80
|
+
code: "range_out_of_bounds",
|
|
81
|
+
message: `Entity range ${entity.offset}-${entity.offset + entity.length} exceeds text length ${textLength}`,
|
|
82
|
+
entityIndex,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (entity.type === "text_link" && !isValidLinkUrl(entity.url)) {
|
|
86
|
+
issues.push({
|
|
87
|
+
code: "invalid_link_url",
|
|
88
|
+
message: `Invalid Telegram text_link URL: ${entity.url}`,
|
|
89
|
+
entityIndex,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return issues;
|
|
93
|
+
}
|
|
94
|
+
function validateEntityPair(left, leftIndex, right, rightIndex) {
|
|
95
|
+
const issues = [];
|
|
96
|
+
if (!rangesOverlap(left, right)) {
|
|
97
|
+
return issues;
|
|
98
|
+
}
|
|
99
|
+
if (left.type === right.type && rangesEqual(left, right)) {
|
|
100
|
+
issues.push({
|
|
101
|
+
code: "duplicate_entity_range",
|
|
102
|
+
message: `Duplicate ${left.type} entities share the same range ${left.offset}-${left.offset + left.length}`,
|
|
103
|
+
entityIndex: rightIndex,
|
|
104
|
+
});
|
|
105
|
+
return issues;
|
|
106
|
+
}
|
|
107
|
+
if (isPartialOverlap(left, right)) {
|
|
108
|
+
issues.push({
|
|
109
|
+
code: "partial_overlap",
|
|
110
|
+
message: `Entities ${left.type} and ${right.type} partially overlap`,
|
|
111
|
+
entityIndex: rightIndex,
|
|
112
|
+
});
|
|
113
|
+
return issues;
|
|
114
|
+
}
|
|
115
|
+
if (left.type === "code" ||
|
|
116
|
+
right.type === "code" ||
|
|
117
|
+
left.type === "pre" ||
|
|
118
|
+
right.type === "pre") {
|
|
119
|
+
issues.push({
|
|
120
|
+
code: left.type === "pre" || right.type === "pre" ? "pre_overlap" : "code_overlap",
|
|
121
|
+
message: `${left.type === "pre" || right.type === "pre" ? "Pre" : "Code"} entities cannot overlap with ${left.type === "code" || left.type === "pre" ? right.type : left.type}`,
|
|
122
|
+
entityIndex: left.type === "code" || left.type === "pre" ? rightIndex : leftIndex,
|
|
123
|
+
});
|
|
124
|
+
return issues;
|
|
125
|
+
}
|
|
126
|
+
if (left.type === "text_link" && right.type === "text_link") {
|
|
127
|
+
issues.push({
|
|
128
|
+
code: "nested_link",
|
|
129
|
+
message: "text_link entities cannot be nested or share the same range",
|
|
130
|
+
entityIndex: rightIndex,
|
|
131
|
+
});
|
|
132
|
+
return issues;
|
|
133
|
+
}
|
|
134
|
+
const styleAndLinkNestingAllowed = (left.type === "text_link" && isStyleEntity(right)) ||
|
|
135
|
+
(right.type === "text_link" && isStyleEntity(left));
|
|
136
|
+
if (!styleAndLinkNestingAllowed && !(isStyleEntity(left) && isStyleEntity(right))) {
|
|
137
|
+
issues.push({
|
|
138
|
+
code: "unsupported_overlap",
|
|
139
|
+
message: `Unsupported overlap between ${left.type} and ${right.type}`,
|
|
140
|
+
entityIndex: rightIndex,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return issues;
|
|
144
|
+
}
|
|
145
|
+
export function validateTelegramEntities(text, entities) {
|
|
146
|
+
const issues = [];
|
|
147
|
+
const textLength = text.length;
|
|
148
|
+
entities.forEach((entity, entityIndex) => {
|
|
149
|
+
issues.push(...validateEntityShape(textLength, entity, entityIndex));
|
|
150
|
+
});
|
|
151
|
+
for (let leftIndex = 0; leftIndex < entities.length; leftIndex++) {
|
|
152
|
+
for (let rightIndex = leftIndex + 1; rightIndex < entities.length; rightIndex++) {
|
|
153
|
+
issues.push(...validateEntityPair(entities[leftIndex], leftIndex, entities[rightIndex], rightIndex));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
ok: issues.length === 0,
|
|
158
|
+
issues,
|
|
159
|
+
};
|
|
160
|
+
}
|