@adminforth/chat-surface-adapter-telegram 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +32 -3
- package/dist/renderers.d.ts +23 -0
- package/dist/renderers.js +361 -0
- package/index.ts +47 -4
- package/package.json +4 -1
- package/renderers.ts +463 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type AdminUser, type ChatSurfaceAdapter, type ChatSurfaceEventSink, type ChatSurfaceIncomingMessage, type ChatSurfaceRequestContext, type IAdminForth } from "adminforth";
|
|
2
2
|
import { AdapterOptions } from "./types.js";
|
|
3
3
|
export type { AdapterOptions, TelegramStreamingMode } from "./types.js";
|
|
4
|
+
export { getFinalMessageStreamPreview, renderFinalMessageImages, renderHtmlBlockToPng, renderTablePng, renderVegaLitePng, type RenderedMessage, type RenderedMessageImage, type RenderTableColumn, type RenderTablePngInput, type VegaLiteSpec, } from "./renderers.js";
|
|
4
5
|
type TelegramUpdate = {
|
|
5
6
|
message?: {
|
|
6
7
|
text?: string;
|
|
@@ -37,6 +38,8 @@ export declare class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
37
38
|
incoming: ChatSurfaceIncomingMessage;
|
|
38
39
|
}): Promise<AdminUser | null>;
|
|
39
40
|
private sendMessage;
|
|
41
|
+
private sendFinalMessage;
|
|
42
|
+
private sendPhoto;
|
|
40
43
|
private sendChatAction;
|
|
41
44
|
private sendMessageDraft;
|
|
42
45
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
10
|
import { AdminForthFilterOperators, } from "adminforth";
|
|
11
|
+
import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
|
|
11
12
|
import { randomInt } from "node:crypto";
|
|
13
|
+
export { getFinalMessageStreamPreview, renderFinalMessageImages, renderHtmlBlockToPng, renderTablePng, renderVegaLitePng, } from "./renderers.js";
|
|
12
14
|
const TELEGRAM_API_BASE_URL = "https://api.telegram.org";
|
|
13
15
|
const TELEGRAM_SECRET_HEADER = "x-telegram-bot-api-secret-token";
|
|
14
16
|
const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
|
|
@@ -117,13 +119,14 @@ export class TelegramChatSurfaceAdapter {
|
|
|
117
119
|
}
|
|
118
120
|
};
|
|
119
121
|
const flushDraft = () => __awaiter(this, void 0, void 0, function* () {
|
|
122
|
+
const draftPreviewText = getFinalMessageStreamPreview(text);
|
|
120
123
|
if (closed ||
|
|
121
124
|
done ||
|
|
122
125
|
streamingMode !== "draft" ||
|
|
123
|
-
!
|
|
126
|
+
!draftPreviewText) {
|
|
124
127
|
return;
|
|
125
128
|
}
|
|
126
|
-
const draftText = truncateTelegramDraft(
|
|
129
|
+
const draftText = truncateTelegramDraft(draftPreviewText);
|
|
127
130
|
if (draftText === lastDraftText) {
|
|
128
131
|
return;
|
|
129
132
|
}
|
|
@@ -161,7 +164,7 @@ export class TelegramChatSurfaceAdapter {
|
|
|
161
164
|
done = true;
|
|
162
165
|
stopTyping();
|
|
163
166
|
clearDraftTimer();
|
|
164
|
-
yield this.
|
|
167
|
+
yield this.sendFinalMessage(chatId, text || event.text);
|
|
165
168
|
return;
|
|
166
169
|
}
|
|
167
170
|
if (event.type === "error") {
|
|
@@ -223,6 +226,32 @@ export class TelegramChatSurfaceAdapter {
|
|
|
223
226
|
}
|
|
224
227
|
});
|
|
225
228
|
}
|
|
229
|
+
sendFinalMessage(chatId, text) {
|
|
230
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
231
|
+
const renderedMessage = yield renderFinalMessageImages(text);
|
|
232
|
+
yield this.sendMessage(chatId, renderedMessage.text);
|
|
233
|
+
for (const image of renderedMessage.images) {
|
|
234
|
+
yield this.sendPhoto(chatId, image.buffer, image.filename);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
sendPhoto(chatId, png, filename) {
|
|
239
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
240
|
+
const photoBytes = new Uint8Array(png);
|
|
241
|
+
const formData = new FormData();
|
|
242
|
+
formData.append("chat_id", chatId);
|
|
243
|
+
formData.append("photo", new Blob([photoBytes], {
|
|
244
|
+
type: "image/png",
|
|
245
|
+
}), filename);
|
|
246
|
+
const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendPhoto`, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
body: formData,
|
|
249
|
+
});
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
throw new Error(`Telegram sendPhoto failed: ${response.status} ${yield response.text()}`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
226
255
|
sendChatAction(chatId, action) {
|
|
227
256
|
return __awaiter(this, void 0, void 0, function* () {
|
|
228
257
|
const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendChatAction`, {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type RenderTableColumn = string | {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
};
|
|
5
|
+
export type RenderTablePngInput = {
|
|
6
|
+
columns: RenderTableColumn[];
|
|
7
|
+
rows: Record<string, unknown>[];
|
|
8
|
+
title?: string;
|
|
9
|
+
};
|
|
10
|
+
export type VegaLiteSpec = Record<string, unknown>;
|
|
11
|
+
export type RenderedMessageImage = {
|
|
12
|
+
buffer: Buffer;
|
|
13
|
+
filename: string;
|
|
14
|
+
};
|
|
15
|
+
export type RenderedMessage = {
|
|
16
|
+
text: string;
|
|
17
|
+
images: RenderedMessageImage[];
|
|
18
|
+
};
|
|
19
|
+
export declare function getFinalMessageStreamPreview(text: string): string;
|
|
20
|
+
export declare function renderHtmlBlockToPng(html: string, selector: string): Promise<Buffer>;
|
|
21
|
+
export declare function renderTablePng(input: RenderTablePngInput): Promise<Buffer>;
|
|
22
|
+
export declare function renderVegaLitePng(spec: VegaLiteSpec): Promise<Buffer>;
|
|
23
|
+
export declare function renderFinalMessageImages(text: string): Promise<RenderedMessage>;
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
const DEFAULT_VIEWPORT_WIDTH = 1200;
|
|
11
|
+
const DEFAULT_VIEWPORT_HEIGHT = 800;
|
|
12
|
+
const HTML_ESCAPE_RE = /[&<>"']/g;
|
|
13
|
+
const VEGA_LITE_BLOCK_RE = /```vega-lite\s*([\s\S]*?)```/g;
|
|
14
|
+
const MARKDOWN_TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
|
15
|
+
const MARKDOWN_TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
|
|
16
|
+
const VEGA_LITE_FENCE_START = "```vega-lite";
|
|
17
|
+
const HTML_ESCAPE_MAP = {
|
|
18
|
+
"&": "&",
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
'"': """,
|
|
22
|
+
"'": "'",
|
|
23
|
+
};
|
|
24
|
+
function escapeHtml(value) {
|
|
25
|
+
return String(value !== null && value !== void 0 ? value : "").replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPE_MAP[char]);
|
|
26
|
+
}
|
|
27
|
+
function splitMarkdownTableRow(line) {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
const withoutOuterPipes = trimmed.startsWith("|") && trimmed.endsWith("|")
|
|
30
|
+
? trimmed.slice(1, -1)
|
|
31
|
+
: trimmed;
|
|
32
|
+
return withoutOuterPipes.split("|").map((cell) => cell.trim());
|
|
33
|
+
}
|
|
34
|
+
function parseMarkdownTable(lines) {
|
|
35
|
+
if (lines.length < 3) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const headers = splitMarkdownTableRow(lines[0]);
|
|
39
|
+
const rows = lines.slice(2).map((line) => splitMarkdownTableRow(line));
|
|
40
|
+
const columns = headers.map((header, index) => ({
|
|
41
|
+
key: String(index),
|
|
42
|
+
label: header || `Column ${index + 1}`,
|
|
43
|
+
}));
|
|
44
|
+
return {
|
|
45
|
+
columns,
|
|
46
|
+
rows: rows.map((row) => Object.fromEntries(columns.map((column, index) => { var _a; return [column.key, (_a = row[index]) !== null && _a !== void 0 ? _a : ""]; }))),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function findVegaLiteBlocks(text) {
|
|
50
|
+
const blocks = [];
|
|
51
|
+
for (const match of text.matchAll(VEGA_LITE_BLOCK_RE)) {
|
|
52
|
+
const source = match[0];
|
|
53
|
+
const json = match[1];
|
|
54
|
+
const start = match.index;
|
|
55
|
+
if (start === undefined) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
blocks.push({
|
|
59
|
+
type: "vega-lite",
|
|
60
|
+
source,
|
|
61
|
+
spec: JSON.parse(json),
|
|
62
|
+
start,
|
|
63
|
+
end: start + source.length,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return blocks;
|
|
67
|
+
}
|
|
68
|
+
function findMarkdownTableBlocks(text, occupiedBlocks) {
|
|
69
|
+
const blocks = [];
|
|
70
|
+
const lines = text.split("\n");
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (let index = 0; index < lines.length - 1; index += 1) {
|
|
73
|
+
const headerLine = lines[index];
|
|
74
|
+
const separatorLine = lines[index + 1];
|
|
75
|
+
const tableStart = offset;
|
|
76
|
+
if (occupiedBlocks.some((block) => tableStart >= block.start && tableStart < block.end)
|
|
77
|
+
|| !MARKDOWN_TABLE_ROW_RE.test(headerLine)
|
|
78
|
+
|| !MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)) {
|
|
79
|
+
offset += headerLine.length + 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const tableLines = [headerLine, separatorLine];
|
|
83
|
+
let endLineIndex = index + 2;
|
|
84
|
+
let tableEnd = tableStart + headerLine.length + 1 + separatorLine.length;
|
|
85
|
+
while (endLineIndex < lines.length && MARKDOWN_TABLE_ROW_RE.test(lines[endLineIndex])) {
|
|
86
|
+
tableEnd += 1 + lines[endLineIndex].length;
|
|
87
|
+
tableLines.push(lines[endLineIndex]);
|
|
88
|
+
endLineIndex += 1;
|
|
89
|
+
}
|
|
90
|
+
const table = parseMarkdownTable(tableLines);
|
|
91
|
+
if (table) {
|
|
92
|
+
blocks.push({
|
|
93
|
+
type: "table",
|
|
94
|
+
source: tableLines.join("\n"),
|
|
95
|
+
table,
|
|
96
|
+
start: tableStart,
|
|
97
|
+
end: tableEnd,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
for (; index < endLineIndex - 1; index += 1) {
|
|
101
|
+
offset += lines[index].length + 1;
|
|
102
|
+
}
|
|
103
|
+
index -= 1;
|
|
104
|
+
}
|
|
105
|
+
return blocks;
|
|
106
|
+
}
|
|
107
|
+
function findFirstMarkdownTableStart(text) {
|
|
108
|
+
const lines = text.split("\n");
|
|
109
|
+
let offset = 0;
|
|
110
|
+
for (let index = 0; index < lines.length - 1; index += 1) {
|
|
111
|
+
const headerLine = lines[index];
|
|
112
|
+
const separatorLine = lines[index + 1];
|
|
113
|
+
if (MARKDOWN_TABLE_ROW_RE.test(headerLine)
|
|
114
|
+
&& MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)) {
|
|
115
|
+
return offset;
|
|
116
|
+
}
|
|
117
|
+
offset += headerLine.length + 1;
|
|
118
|
+
}
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
function getPartialVegaLiteFenceStartLength(text) {
|
|
122
|
+
for (let length = Math.min(text.length, VEGA_LITE_FENCE_START.length - 1); length > 0; length -= 1) {
|
|
123
|
+
if (VEGA_LITE_FENCE_START.startsWith(text.slice(-length))) {
|
|
124
|
+
return length;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
function normalizeTextAfterBlockRemoval(text) {
|
|
130
|
+
return text
|
|
131
|
+
.split("\n")
|
|
132
|
+
.map((line) => line.trimEnd())
|
|
133
|
+
.join("\n")
|
|
134
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
135
|
+
.trim();
|
|
136
|
+
}
|
|
137
|
+
export function getFinalMessageStreamPreview(text) {
|
|
138
|
+
const vegaLiteStart = text.indexOf(VEGA_LITE_FENCE_START);
|
|
139
|
+
const tableStart = findFirstMarkdownTableStart(text);
|
|
140
|
+
const renderableStarts = [vegaLiteStart, tableStart].filter((start) => start >= 0);
|
|
141
|
+
if (renderableStarts.length) {
|
|
142
|
+
return text.slice(0, Math.min(...renderableStarts)).trimEnd();
|
|
143
|
+
}
|
|
144
|
+
const partialVegaLiteFenceStartLength = getPartialVegaLiteFenceStartLength(text);
|
|
145
|
+
if (!partialVegaLiteFenceStartLength) {
|
|
146
|
+
return text;
|
|
147
|
+
}
|
|
148
|
+
return text.slice(0, -partialVegaLiteFenceStartLength);
|
|
149
|
+
}
|
|
150
|
+
function getChromium() {
|
|
151
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
152
|
+
const { chromium } = yield import("playwright");
|
|
153
|
+
return chromium;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
export function renderHtmlBlockToPng(html, selector) {
|
|
157
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
158
|
+
const chromium = yield getChromium();
|
|
159
|
+
const browser = yield chromium.launch({
|
|
160
|
+
headless: true,
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
const page = yield browser.newPage({
|
|
164
|
+
viewport: {
|
|
165
|
+
width: DEFAULT_VIEWPORT_WIDTH,
|
|
166
|
+
height: DEFAULT_VIEWPORT_HEIGHT,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
yield page.setContent(html, {
|
|
170
|
+
waitUntil: "networkidle",
|
|
171
|
+
});
|
|
172
|
+
yield page.evaluate(() => document.fonts.ready);
|
|
173
|
+
const hasRenderReady = yield page.evaluate(() => (Boolean(window.__adminforthRenderReady)));
|
|
174
|
+
if (hasRenderReady) {
|
|
175
|
+
yield page.waitForFunction(() => {
|
|
176
|
+
const renderState = window;
|
|
177
|
+
return renderState.__adminforthRenderDone || renderState.__adminforthRenderError;
|
|
178
|
+
});
|
|
179
|
+
const renderError = yield page.evaluate(() => (window.__adminforthRenderError));
|
|
180
|
+
if (renderError) {
|
|
181
|
+
throw new Error(renderError);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const block = page.locator(selector);
|
|
185
|
+
yield block.waitFor({
|
|
186
|
+
state: "visible",
|
|
187
|
+
});
|
|
188
|
+
return yield block.screenshot({
|
|
189
|
+
type: "png",
|
|
190
|
+
animations: "disabled",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
yield browser.close();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
export function renderTablePng(input) {
|
|
199
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
200
|
+
const normalizedColumns = input.columns.map((column) => (typeof column === "string"
|
|
201
|
+
? { key: column, label: column }
|
|
202
|
+
: column));
|
|
203
|
+
const headerHtml = normalizedColumns
|
|
204
|
+
.map((column) => `<th>${escapeHtml(column.label)}</th>`)
|
|
205
|
+
.join("");
|
|
206
|
+
const rowsHtml = input.rows
|
|
207
|
+
.map((row) => (`<tr>${normalizedColumns
|
|
208
|
+
.map((column) => `<td>${escapeHtml(row[column.key])}</td>`)
|
|
209
|
+
.join("")}</tr>`))
|
|
210
|
+
.join("");
|
|
211
|
+
return renderHtmlBlockToPng(`
|
|
212
|
+
<!doctype html>
|
|
213
|
+
<html>
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="utf-8" />
|
|
216
|
+
<style>
|
|
217
|
+
body {
|
|
218
|
+
margin: 0;
|
|
219
|
+
background: #f6f8fa;
|
|
220
|
+
color: #17202a;
|
|
221
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#adminforth-table-render {
|
|
225
|
+
display: inline-block;
|
|
226
|
+
padding: 28px;
|
|
227
|
+
background: #ffffff;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
h1 {
|
|
231
|
+
margin: 0 0 18px;
|
|
232
|
+
font-size: 24px;
|
|
233
|
+
font-weight: 700;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
table {
|
|
237
|
+
border-collapse: collapse;
|
|
238
|
+
min-width: 560px;
|
|
239
|
+
max-width: 1120px;
|
|
240
|
+
font-size: 15px;
|
|
241
|
+
line-height: 1.45;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
th,
|
|
245
|
+
td {
|
|
246
|
+
padding: 11px 14px;
|
|
247
|
+
border: 1px solid #d8dee4;
|
|
248
|
+
text-align: left;
|
|
249
|
+
vertical-align: top;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
th {
|
|
253
|
+
background: #eef2f6;
|
|
254
|
+
font-weight: 700;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
tr:nth-child(even) td {
|
|
258
|
+
background: #f9fbfc;
|
|
259
|
+
}
|
|
260
|
+
</style>
|
|
261
|
+
</head>
|
|
262
|
+
<body>
|
|
263
|
+
<section id="adminforth-table-render">
|
|
264
|
+
${input.title ? `<h1>${escapeHtml(input.title)}</h1>` : ""}
|
|
265
|
+
<table>
|
|
266
|
+
<thead>
|
|
267
|
+
<tr>${headerHtml}</tr>
|
|
268
|
+
</thead>
|
|
269
|
+
<tbody>${rowsHtml}</tbody>
|
|
270
|
+
</table>
|
|
271
|
+
</section>
|
|
272
|
+
</body>
|
|
273
|
+
</html>
|
|
274
|
+
`, "#adminforth-table-render");
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
export function renderVegaLitePng(spec) {
|
|
278
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
279
|
+
return renderHtmlBlockToPng(`
|
|
280
|
+
<!doctype html>
|
|
281
|
+
<html>
|
|
282
|
+
<head>
|
|
283
|
+
<meta charset="utf-8" />
|
|
284
|
+
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
|
|
285
|
+
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
|
|
286
|
+
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
|
|
287
|
+
<style>
|
|
288
|
+
body {
|
|
289
|
+
margin: 0;
|
|
290
|
+
background: #ffffff;
|
|
291
|
+
color: #17202a;
|
|
292
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#adminforth-vega-lite-render {
|
|
296
|
+
display: inline-block;
|
|
297
|
+
padding: 24px;
|
|
298
|
+
background: #ffffff;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#adminforth-vega-lite-render details {
|
|
302
|
+
display: none;
|
|
303
|
+
}
|
|
304
|
+
</style>
|
|
305
|
+
</head>
|
|
306
|
+
<body>
|
|
307
|
+
<section id="adminforth-vega-lite-render"></section>
|
|
308
|
+
<script>
|
|
309
|
+
window.__adminforthRenderReady = vegaEmbed(
|
|
310
|
+
"#adminforth-vega-lite-render",
|
|
311
|
+
${JSON.stringify(spec)},
|
|
312
|
+
{ actions: false, renderer: "svg" }
|
|
313
|
+
)
|
|
314
|
+
.then(function () {
|
|
315
|
+
window.__adminforthRenderDone = true;
|
|
316
|
+
})
|
|
317
|
+
.catch(function (error) {
|
|
318
|
+
window.__adminforthRenderError = String(error && error.message ? error.message : error);
|
|
319
|
+
});
|
|
320
|
+
</script>
|
|
321
|
+
</body>
|
|
322
|
+
</html>
|
|
323
|
+
`, "#adminforth-vega-lite-render");
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
export function renderFinalMessageImages(text) {
|
|
327
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
328
|
+
const vegaLiteBlocks = findVegaLiteBlocks(text);
|
|
329
|
+
const tableBlocks = findMarkdownTableBlocks(text, vegaLiteBlocks);
|
|
330
|
+
const blocks = [...vegaLiteBlocks, ...tableBlocks].sort((a, b) => a.start - b.start);
|
|
331
|
+
if (!blocks.length) {
|
|
332
|
+
return {
|
|
333
|
+
text,
|
|
334
|
+
images: [],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const remainingTextParts = [];
|
|
338
|
+
const images = [];
|
|
339
|
+
let cursor = 0;
|
|
340
|
+
for (const [index, block] of blocks.entries()) {
|
|
341
|
+
remainingTextParts.push(text.slice(cursor, block.start));
|
|
342
|
+
cursor = block.end;
|
|
343
|
+
if (block.type === "vega-lite") {
|
|
344
|
+
images.push({
|
|
345
|
+
buffer: yield renderVegaLitePng(block.spec),
|
|
346
|
+
filename: `chart-${index + 1}.png`,
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
images.push({
|
|
351
|
+
buffer: yield renderTablePng(block.table),
|
|
352
|
+
filename: `table-${index + 1}.png`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
remainingTextParts.push(text.slice(cursor));
|
|
356
|
+
return {
|
|
357
|
+
text: normalizeTextAfterBlockRemoval(remainingTextParts.join("")),
|
|
358
|
+
images,
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
}
|
package/index.ts
CHANGED
|
@@ -8,8 +8,21 @@ import {
|
|
|
8
8
|
type IAdminForth,
|
|
9
9
|
} from "adminforth";
|
|
10
10
|
import { AdapterOptions } from "./types.js";
|
|
11
|
+
import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
|
|
11
12
|
import { randomInt } from "node:crypto";
|
|
12
13
|
export type { AdapterOptions, TelegramStreamingMode } from "./types.js";
|
|
14
|
+
export {
|
|
15
|
+
getFinalMessageStreamPreview,
|
|
16
|
+
renderFinalMessageImages,
|
|
17
|
+
renderHtmlBlockToPng,
|
|
18
|
+
renderTablePng,
|
|
19
|
+
renderVegaLitePng,
|
|
20
|
+
type RenderedMessage,
|
|
21
|
+
type RenderedMessageImage,
|
|
22
|
+
type RenderTableColumn,
|
|
23
|
+
type RenderTablePngInput,
|
|
24
|
+
type VegaLiteSpec,
|
|
25
|
+
} from "./renderers.js";
|
|
13
26
|
|
|
14
27
|
type TelegramUpdate = {
|
|
15
28
|
message?: {
|
|
@@ -165,16 +178,18 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
165
178
|
};
|
|
166
179
|
|
|
167
180
|
const flushDraft = async () => {
|
|
181
|
+
const draftPreviewText = getFinalMessageStreamPreview(text);
|
|
182
|
+
|
|
168
183
|
if (
|
|
169
184
|
closed ||
|
|
170
185
|
done ||
|
|
171
186
|
streamingMode !== "draft" ||
|
|
172
|
-
!
|
|
187
|
+
!draftPreviewText
|
|
173
188
|
) {
|
|
174
189
|
return;
|
|
175
190
|
}
|
|
176
191
|
|
|
177
|
-
const draftText = truncateTelegramDraft(
|
|
192
|
+
const draftText = truncateTelegramDraft(draftPreviewText);
|
|
178
193
|
|
|
179
194
|
if (draftText === lastDraftText) {
|
|
180
195
|
return;
|
|
@@ -225,7 +240,7 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
225
240
|
stopTyping();
|
|
226
241
|
clearDraftTimer();
|
|
227
242
|
|
|
228
|
-
await this.
|
|
243
|
+
await this.sendFinalMessage(
|
|
229
244
|
chatId,
|
|
230
245
|
text || event.text,
|
|
231
246
|
);
|
|
@@ -301,6 +316,34 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
301
316
|
}
|
|
302
317
|
}
|
|
303
318
|
|
|
319
|
+
private async sendFinalMessage(chatId: string, text: string) {
|
|
320
|
+
const renderedMessage = await renderFinalMessageImages(text);
|
|
321
|
+
|
|
322
|
+
await this.sendMessage(chatId, renderedMessage.text);
|
|
323
|
+
|
|
324
|
+
for (const image of renderedMessage.images) {
|
|
325
|
+
await this.sendPhoto(chatId, image.buffer, image.filename);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async sendPhoto(chatId: string, png: Buffer, filename: string) {
|
|
330
|
+
const photoBytes = new Uint8Array(png);
|
|
331
|
+
const formData = new FormData();
|
|
332
|
+
formData.append("chat_id", chatId);
|
|
333
|
+
formData.append("photo", new Blob([photoBytes], {
|
|
334
|
+
type: "image/png",
|
|
335
|
+
}), filename);
|
|
336
|
+
|
|
337
|
+
const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendPhoto`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
body: formData,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
throw new Error(`Telegram sendPhoto failed: ${response.status} ${await response.text()}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
304
347
|
private async sendChatAction(chatId: string, action: "typing") {
|
|
305
348
|
const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendChatAction`, {
|
|
306
349
|
method: "POST",
|
|
@@ -343,4 +386,4 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
343
386
|
}
|
|
344
387
|
}
|
|
345
388
|
|
|
346
|
-
export default TelegramChatSurfaceAdapter;
|
|
389
|
+
export default TelegramChatSurfaceAdapter;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adminforth/chat-surface-adapter-telegram",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
"semantic-release-slack-bot": "^4.0.2",
|
|
30
30
|
"typescript": "^5.9.3"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"playwright": "^1.57.0"
|
|
34
|
+
},
|
|
32
35
|
"peerDependencies": {
|
|
33
36
|
"@adminforth/agent": ">=1.0.2",
|
|
34
37
|
"adminforth": ">=2.59.0"
|
package/renderers.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
type PlaywrightChromium = typeof import("playwright").chromium;
|
|
2
|
+
|
|
3
|
+
export type RenderTableColumn =
|
|
4
|
+
| string
|
|
5
|
+
| {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RenderTablePngInput = {
|
|
11
|
+
columns: RenderTableColumn[];
|
|
12
|
+
rows: Record<string, unknown>[];
|
|
13
|
+
title?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type VegaLiteSpec = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
export type RenderedMessageImage = {
|
|
19
|
+
buffer: Buffer;
|
|
20
|
+
filename: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RenderedMessage = {
|
|
24
|
+
text: string;
|
|
25
|
+
images: RenderedMessageImage[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type RenderableBlock =
|
|
29
|
+
| {
|
|
30
|
+
type: "vega-lite";
|
|
31
|
+
source: string;
|
|
32
|
+
spec: VegaLiteSpec;
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
type: "table";
|
|
38
|
+
source: string;
|
|
39
|
+
table: RenderTablePngInput;
|
|
40
|
+
start: number;
|
|
41
|
+
end: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DEFAULT_VIEWPORT_WIDTH = 1200;
|
|
45
|
+
const DEFAULT_VIEWPORT_HEIGHT = 800;
|
|
46
|
+
const HTML_ESCAPE_RE = /[&<>"']/g;
|
|
47
|
+
const VEGA_LITE_BLOCK_RE = /```vega-lite\s*([\s\S]*?)```/g;
|
|
48
|
+
const MARKDOWN_TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
|
49
|
+
const MARKDOWN_TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
|
|
50
|
+
const VEGA_LITE_FENCE_START = "```vega-lite";
|
|
51
|
+
const HTML_ESCAPE_MAP: Record<string, string> = {
|
|
52
|
+
"&": "&",
|
|
53
|
+
"<": "<",
|
|
54
|
+
">": ">",
|
|
55
|
+
'"': """,
|
|
56
|
+
"'": "'",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function escapeHtml(value: unknown) {
|
|
60
|
+
return String(value ?? "").replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPE_MAP[char]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function splitMarkdownTableRow(line: string) {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
const withoutOuterPipes = trimmed.startsWith("|") && trimmed.endsWith("|")
|
|
66
|
+
? trimmed.slice(1, -1)
|
|
67
|
+
: trimmed;
|
|
68
|
+
|
|
69
|
+
return withoutOuterPipes.split("|").map((cell) => cell.trim());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseMarkdownTable(lines: string[]): RenderTablePngInput | null {
|
|
73
|
+
if (lines.length < 3) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const headers = splitMarkdownTableRow(lines[0]);
|
|
78
|
+
const rows = lines.slice(2).map((line) => splitMarkdownTableRow(line));
|
|
79
|
+
const columns = headers.map((header, index) => ({
|
|
80
|
+
key: String(index),
|
|
81
|
+
label: header || `Column ${index + 1}`,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
columns,
|
|
86
|
+
rows: rows.map((row) => Object.fromEntries(
|
|
87
|
+
columns.map((column, index) => [column.key, row[index] ?? ""]),
|
|
88
|
+
)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findVegaLiteBlocks(text: string): RenderableBlock[] {
|
|
93
|
+
const blocks: RenderableBlock[] = [];
|
|
94
|
+
|
|
95
|
+
for (const match of text.matchAll(VEGA_LITE_BLOCK_RE)) {
|
|
96
|
+
const source = match[0];
|
|
97
|
+
const json = match[1];
|
|
98
|
+
const start = match.index;
|
|
99
|
+
|
|
100
|
+
if (start === undefined) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
blocks.push({
|
|
105
|
+
type: "vega-lite",
|
|
106
|
+
source,
|
|
107
|
+
spec: JSON.parse(json),
|
|
108
|
+
start,
|
|
109
|
+
end: start + source.length,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return blocks;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findMarkdownTableBlocks(text: string, occupiedBlocks: RenderableBlock[]): RenderableBlock[] {
|
|
117
|
+
const blocks: RenderableBlock[] = [];
|
|
118
|
+
const lines = text.split("\n");
|
|
119
|
+
let offset = 0;
|
|
120
|
+
|
|
121
|
+
for (let index = 0; index < lines.length - 1; index += 1) {
|
|
122
|
+
const headerLine = lines[index];
|
|
123
|
+
const separatorLine = lines[index + 1];
|
|
124
|
+
const tableStart = offset;
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
occupiedBlocks.some((block) => tableStart >= block.start && tableStart < block.end)
|
|
128
|
+
|| !MARKDOWN_TABLE_ROW_RE.test(headerLine)
|
|
129
|
+
|| !MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)
|
|
130
|
+
) {
|
|
131
|
+
offset += headerLine.length + 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const tableLines = [headerLine, separatorLine];
|
|
136
|
+
let endLineIndex = index + 2;
|
|
137
|
+
let tableEnd = tableStart + headerLine.length + 1 + separatorLine.length;
|
|
138
|
+
|
|
139
|
+
while (endLineIndex < lines.length && MARKDOWN_TABLE_ROW_RE.test(lines[endLineIndex])) {
|
|
140
|
+
tableEnd += 1 + lines[endLineIndex].length;
|
|
141
|
+
tableLines.push(lines[endLineIndex]);
|
|
142
|
+
endLineIndex += 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const table = parseMarkdownTable(tableLines);
|
|
146
|
+
|
|
147
|
+
if (table) {
|
|
148
|
+
blocks.push({
|
|
149
|
+
type: "table",
|
|
150
|
+
source: tableLines.join("\n"),
|
|
151
|
+
table,
|
|
152
|
+
start: tableStart,
|
|
153
|
+
end: tableEnd,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (; index < endLineIndex - 1; index += 1) {
|
|
158
|
+
offset += lines[index].length + 1;
|
|
159
|
+
}
|
|
160
|
+
index -= 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return blocks;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function findFirstMarkdownTableStart(text: string) {
|
|
167
|
+
const lines = text.split("\n");
|
|
168
|
+
let offset = 0;
|
|
169
|
+
|
|
170
|
+
for (let index = 0; index < lines.length - 1; index += 1) {
|
|
171
|
+
const headerLine = lines[index];
|
|
172
|
+
const separatorLine = lines[index + 1];
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
MARKDOWN_TABLE_ROW_RE.test(headerLine)
|
|
176
|
+
&& MARKDOWN_TABLE_SEPARATOR_RE.test(separatorLine)
|
|
177
|
+
) {
|
|
178
|
+
return offset;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
offset += headerLine.length + 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return -1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getPartialVegaLiteFenceStartLength(text: string) {
|
|
188
|
+
for (let length = Math.min(text.length, VEGA_LITE_FENCE_START.length - 1); length > 0; length -= 1) {
|
|
189
|
+
if (VEGA_LITE_FENCE_START.startsWith(text.slice(-length))) {
|
|
190
|
+
return length;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeTextAfterBlockRemoval(text: string) {
|
|
198
|
+
return text
|
|
199
|
+
.split("\n")
|
|
200
|
+
.map((line) => line.trimEnd())
|
|
201
|
+
.join("\n")
|
|
202
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
203
|
+
.trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function getFinalMessageStreamPreview(text: string) {
|
|
207
|
+
const vegaLiteStart = text.indexOf(VEGA_LITE_FENCE_START);
|
|
208
|
+
const tableStart = findFirstMarkdownTableStart(text);
|
|
209
|
+
const renderableStarts = [vegaLiteStart, tableStart].filter((start) => start >= 0);
|
|
210
|
+
|
|
211
|
+
if (renderableStarts.length) {
|
|
212
|
+
return text.slice(0, Math.min(...renderableStarts)).trimEnd();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const partialVegaLiteFenceStartLength = getPartialVegaLiteFenceStartLength(text);
|
|
216
|
+
|
|
217
|
+
if (!partialVegaLiteFenceStartLength) {
|
|
218
|
+
return text;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return text.slice(0, -partialVegaLiteFenceStartLength);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function getChromium(): Promise<PlaywrightChromium> {
|
|
225
|
+
const { chromium } = await import("playwright");
|
|
226
|
+
|
|
227
|
+
return chromium;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function renderHtmlBlockToPng(html: string, selector: string): Promise<Buffer> {
|
|
231
|
+
const chromium = await getChromium();
|
|
232
|
+
const browser = await chromium.launch({
|
|
233
|
+
headless: true,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const page = await browser.newPage({
|
|
238
|
+
viewport: {
|
|
239
|
+
width: DEFAULT_VIEWPORT_WIDTH,
|
|
240
|
+
height: DEFAULT_VIEWPORT_HEIGHT,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await page.setContent(html, {
|
|
245
|
+
waitUntil: "networkidle",
|
|
246
|
+
});
|
|
247
|
+
await page.evaluate(() => document.fonts.ready);
|
|
248
|
+
const hasRenderReady = await page.evaluate(() => (
|
|
249
|
+
Boolean((window as typeof window & {
|
|
250
|
+
__adminforthRenderReady?: Promise<void>;
|
|
251
|
+
}).__adminforthRenderReady)
|
|
252
|
+
));
|
|
253
|
+
|
|
254
|
+
if (hasRenderReady) {
|
|
255
|
+
await page.waitForFunction(() => {
|
|
256
|
+
const renderState = window as typeof window & {
|
|
257
|
+
__adminforthRenderDone?: boolean;
|
|
258
|
+
__adminforthRenderError?: string;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return renderState.__adminforthRenderDone || renderState.__adminforthRenderError;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const renderError = await page.evaluate(() => (
|
|
265
|
+
(window as typeof window & {
|
|
266
|
+
__adminforthRenderError?: string;
|
|
267
|
+
}).__adminforthRenderError
|
|
268
|
+
));
|
|
269
|
+
|
|
270
|
+
if (renderError) {
|
|
271
|
+
throw new Error(renderError);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const block = page.locator(selector);
|
|
276
|
+
await block.waitFor({
|
|
277
|
+
state: "visible",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return await block.screenshot({
|
|
281
|
+
type: "png",
|
|
282
|
+
animations: "disabled",
|
|
283
|
+
});
|
|
284
|
+
} finally {
|
|
285
|
+
await browser.close();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function renderTablePng(input: RenderTablePngInput): Promise<Buffer> {
|
|
290
|
+
const normalizedColumns = input.columns.map((column) => (
|
|
291
|
+
typeof column === "string"
|
|
292
|
+
? { key: column, label: column }
|
|
293
|
+
: column
|
|
294
|
+
));
|
|
295
|
+
|
|
296
|
+
const headerHtml = normalizedColumns
|
|
297
|
+
.map((column) => `<th>${escapeHtml(column.label)}</th>`)
|
|
298
|
+
.join("");
|
|
299
|
+
const rowsHtml = input.rows
|
|
300
|
+
.map((row) => (
|
|
301
|
+
`<tr>${
|
|
302
|
+
normalizedColumns
|
|
303
|
+
.map((column) => `<td>${escapeHtml(row[column.key])}</td>`)
|
|
304
|
+
.join("")
|
|
305
|
+
}</tr>`
|
|
306
|
+
))
|
|
307
|
+
.join("");
|
|
308
|
+
|
|
309
|
+
return renderHtmlBlockToPng(`
|
|
310
|
+
<!doctype html>
|
|
311
|
+
<html>
|
|
312
|
+
<head>
|
|
313
|
+
<meta charset="utf-8" />
|
|
314
|
+
<style>
|
|
315
|
+
body {
|
|
316
|
+
margin: 0;
|
|
317
|
+
background: #f6f8fa;
|
|
318
|
+
color: #17202a;
|
|
319
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#adminforth-table-render {
|
|
323
|
+
display: inline-block;
|
|
324
|
+
padding: 28px;
|
|
325
|
+
background: #ffffff;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
h1 {
|
|
329
|
+
margin: 0 0 18px;
|
|
330
|
+
font-size: 24px;
|
|
331
|
+
font-weight: 700;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
table {
|
|
335
|
+
border-collapse: collapse;
|
|
336
|
+
min-width: 560px;
|
|
337
|
+
max-width: 1120px;
|
|
338
|
+
font-size: 15px;
|
|
339
|
+
line-height: 1.45;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
th,
|
|
343
|
+
td {
|
|
344
|
+
padding: 11px 14px;
|
|
345
|
+
border: 1px solid #d8dee4;
|
|
346
|
+
text-align: left;
|
|
347
|
+
vertical-align: top;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
th {
|
|
351
|
+
background: #eef2f6;
|
|
352
|
+
font-weight: 700;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
tr:nth-child(even) td {
|
|
356
|
+
background: #f9fbfc;
|
|
357
|
+
}
|
|
358
|
+
</style>
|
|
359
|
+
</head>
|
|
360
|
+
<body>
|
|
361
|
+
<section id="adminforth-table-render">
|
|
362
|
+
${input.title ? `<h1>${escapeHtml(input.title)}</h1>` : ""}
|
|
363
|
+
<table>
|
|
364
|
+
<thead>
|
|
365
|
+
<tr>${headerHtml}</tr>
|
|
366
|
+
</thead>
|
|
367
|
+
<tbody>${rowsHtml}</tbody>
|
|
368
|
+
</table>
|
|
369
|
+
</section>
|
|
370
|
+
</body>
|
|
371
|
+
</html>
|
|
372
|
+
`, "#adminforth-table-render");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function renderVegaLitePng(spec: VegaLiteSpec): Promise<Buffer> {
|
|
376
|
+
return renderHtmlBlockToPng(`
|
|
377
|
+
<!doctype html>
|
|
378
|
+
<html>
|
|
379
|
+
<head>
|
|
380
|
+
<meta charset="utf-8" />
|
|
381
|
+
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
|
|
382
|
+
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
|
|
383
|
+
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
|
|
384
|
+
<style>
|
|
385
|
+
body {
|
|
386
|
+
margin: 0;
|
|
387
|
+
background: #ffffff;
|
|
388
|
+
color: #17202a;
|
|
389
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#adminforth-vega-lite-render {
|
|
393
|
+
display: inline-block;
|
|
394
|
+
padding: 24px;
|
|
395
|
+
background: #ffffff;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#adminforth-vega-lite-render details {
|
|
399
|
+
display: none;
|
|
400
|
+
}
|
|
401
|
+
</style>
|
|
402
|
+
</head>
|
|
403
|
+
<body>
|
|
404
|
+
<section id="adminforth-vega-lite-render"></section>
|
|
405
|
+
<script>
|
|
406
|
+
window.__adminforthRenderReady = vegaEmbed(
|
|
407
|
+
"#adminforth-vega-lite-render",
|
|
408
|
+
${JSON.stringify(spec)},
|
|
409
|
+
{ actions: false, renderer: "svg" }
|
|
410
|
+
)
|
|
411
|
+
.then(function () {
|
|
412
|
+
window.__adminforthRenderDone = true;
|
|
413
|
+
})
|
|
414
|
+
.catch(function (error) {
|
|
415
|
+
window.__adminforthRenderError = String(error && error.message ? error.message : error);
|
|
416
|
+
});
|
|
417
|
+
</script>
|
|
418
|
+
</body>
|
|
419
|
+
</html>
|
|
420
|
+
`, "#adminforth-vega-lite-render");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function renderFinalMessageImages(text: string): Promise<RenderedMessage> {
|
|
424
|
+
const vegaLiteBlocks = findVegaLiteBlocks(text);
|
|
425
|
+
const tableBlocks = findMarkdownTableBlocks(text, vegaLiteBlocks);
|
|
426
|
+
const blocks = [...vegaLiteBlocks, ...tableBlocks].sort((a, b) => a.start - b.start);
|
|
427
|
+
|
|
428
|
+
if (!blocks.length) {
|
|
429
|
+
return {
|
|
430
|
+
text,
|
|
431
|
+
images: [],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const remainingTextParts: string[] = [];
|
|
436
|
+
const images: RenderedMessageImage[] = [];
|
|
437
|
+
let cursor = 0;
|
|
438
|
+
|
|
439
|
+
for (const [index, block] of blocks.entries()) {
|
|
440
|
+
remainingTextParts.push(text.slice(cursor, block.start));
|
|
441
|
+
cursor = block.end;
|
|
442
|
+
|
|
443
|
+
if (block.type === "vega-lite") {
|
|
444
|
+
images.push({
|
|
445
|
+
buffer: await renderVegaLitePng(block.spec),
|
|
446
|
+
filename: `chart-${index + 1}.png`,
|
|
447
|
+
});
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
images.push({
|
|
452
|
+
buffer: await renderTablePng(block.table),
|
|
453
|
+
filename: `table-${index + 1}.png`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
remainingTextParts.push(text.slice(cursor));
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
text: normalizeTextAfterBlockRemoval(remainingTextParts.join("")),
|
|
461
|
+
images,
|
|
462
|
+
};
|
|
463
|
+
}
|