@adminforth/chat-surface-adapter-telegram 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +13 -5
- package/dist/index.js +50 -28
- package/dist/renderers.d.ts +23 -0
- package/dist/renderers.js +361 -0
- package/dist/types.d.ts +4 -10
- package/index.ts +79 -36
- package/package.json +5 -2
- package/renderers.ts +463 -0
- package/types.ts +5 -12
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ChatSurfaceAdapter, type ChatSurfaceEventSink, type ChatSurfaceIncomingMessage, type ChatSurfaceRequestContext } 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;
|
|
@@ -16,9 +17,17 @@ type TelegramUpdate = {
|
|
|
16
17
|
};
|
|
17
18
|
};
|
|
18
19
|
};
|
|
20
|
+
type ChatSurfaceConnectAction = {
|
|
21
|
+
type: "url";
|
|
22
|
+
label: string;
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
19
25
|
export declare class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
20
26
|
private options;
|
|
21
27
|
name: string;
|
|
28
|
+
createConnectAction?: (input: {
|
|
29
|
+
token: string;
|
|
30
|
+
}) => ChatSurfaceConnectAction;
|
|
22
31
|
constructor(options: AdapterOptions);
|
|
23
32
|
validate(): void;
|
|
24
33
|
parseIncomingMessage(ctx: ChatSurfaceRequestContext): Promise<{
|
|
@@ -28,15 +37,14 @@ export declare class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
28
37
|
externalUserId: string;
|
|
29
38
|
userTimeZone: string;
|
|
30
39
|
metadata: {
|
|
40
|
+
startPayload: string | null;
|
|
31
41
|
telegramUpdate: TelegramUpdate;
|
|
32
42
|
};
|
|
33
43
|
} | null>;
|
|
34
44
|
createEventSink(ctx: ChatSurfaceRequestContext, incoming: ChatSurfaceIncomingMessage): ChatSurfaceEventSink;
|
|
35
|
-
resolveAdminUser(input: {
|
|
36
|
-
adminforth: IAdminForth;
|
|
37
|
-
incoming: ChatSurfaceIncomingMessage;
|
|
38
|
-
}): Promise<AdminUser | null>;
|
|
39
45
|
private sendMessage;
|
|
46
|
+
private sendFinalMessage;
|
|
47
|
+
private sendPhoto;
|
|
40
48
|
private sendChatAction;
|
|
41
49
|
private sendMessageDraft;
|
|
42
50
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,16 +7,17 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
-
import {
|
|
10
|
+
import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
|
|
11
11
|
import { randomInt } from "node:crypto";
|
|
12
|
+
export { getFinalMessageStreamPreview, renderFinalMessageImages, renderHtmlBlockToPng, renderTablePng, renderVegaLitePng, } from "./renderers.js";
|
|
12
13
|
const TELEGRAM_API_BASE_URL = "https://api.telegram.org";
|
|
13
14
|
const TELEGRAM_SECRET_HEADER = "x-telegram-bot-api-secret-token";
|
|
14
15
|
const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
|
|
15
16
|
const TELEGRAM_DRAFT_MAX_LENGTH = 4096;
|
|
16
17
|
const DEFAULT_DRAFT_UPDATE_INTERVAL_MS = 650;
|
|
17
18
|
const DEFAULT_TYPING_INTERVAL_MS = 4000;
|
|
18
|
-
const
|
|
19
|
-
const
|
|
19
|
+
const TELEGRAM_START_COMMAND_PREFIX = "/start";
|
|
20
|
+
const TELEGRAM_COMMAND_PARTS_RE = /\s+/;
|
|
20
21
|
function createTelegramDraftId() {
|
|
21
22
|
return randomInt(1, 2147483647);
|
|
22
23
|
}
|
|
@@ -44,10 +45,24 @@ function splitTelegramMessage(text) {
|
|
|
44
45
|
}
|
|
45
46
|
return chunks;
|
|
46
47
|
}
|
|
48
|
+
function parseTelegramStartPayload(text) {
|
|
49
|
+
const [command, ...payloadParts] = text.trim().split(TELEGRAM_COMMAND_PARTS_RE);
|
|
50
|
+
if (command !== TELEGRAM_START_COMMAND_PREFIX && !command.startsWith(`${TELEGRAM_START_COMMAND_PREFIX}@`)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return payloadParts.join(" ") || null;
|
|
54
|
+
}
|
|
47
55
|
export class TelegramChatSurfaceAdapter {
|
|
48
56
|
constructor(options) {
|
|
49
57
|
this.options = options;
|
|
50
58
|
this.name = "telegram";
|
|
59
|
+
if (options.botUsername) {
|
|
60
|
+
this.createConnectAction = ({ token }) => ({
|
|
61
|
+
type: "url",
|
|
62
|
+
label: "Connect Telegram",
|
|
63
|
+
url: `https://t.me/${options.botUsername}?start=${encodeURIComponent(token)}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
51
66
|
}
|
|
52
67
|
validate() {
|
|
53
68
|
if (!this.options.botToken) {
|
|
@@ -68,6 +83,7 @@ export class TelegramChatSurfaceAdapter {
|
|
|
68
83
|
if (!text || chatId === undefined || userId === undefined) {
|
|
69
84
|
return null;
|
|
70
85
|
}
|
|
86
|
+
const startPayload = parseTelegramStartPayload(text);
|
|
71
87
|
return {
|
|
72
88
|
surface: this.name,
|
|
73
89
|
prompt: text,
|
|
@@ -75,6 +91,7 @@ export class TelegramChatSurfaceAdapter {
|
|
|
75
91
|
externalUserId: String(userId),
|
|
76
92
|
userTimeZone: "UTC",
|
|
77
93
|
metadata: {
|
|
94
|
+
startPayload,
|
|
78
95
|
telegramUpdate: update,
|
|
79
96
|
},
|
|
80
97
|
};
|
|
@@ -117,13 +134,14 @@ export class TelegramChatSurfaceAdapter {
|
|
|
117
134
|
}
|
|
118
135
|
};
|
|
119
136
|
const flushDraft = () => __awaiter(this, void 0, void 0, function* () {
|
|
137
|
+
const draftPreviewText = getFinalMessageStreamPreview(text);
|
|
120
138
|
if (closed ||
|
|
121
139
|
done ||
|
|
122
140
|
streamingMode !== "draft" ||
|
|
123
|
-
!
|
|
141
|
+
!draftPreviewText) {
|
|
124
142
|
return;
|
|
125
143
|
}
|
|
126
|
-
const draftText = truncateTelegramDraft(
|
|
144
|
+
const draftText = truncateTelegramDraft(draftPreviewText);
|
|
127
145
|
if (draftText === lastDraftText) {
|
|
128
146
|
return;
|
|
129
147
|
}
|
|
@@ -161,7 +179,7 @@ export class TelegramChatSurfaceAdapter {
|
|
|
161
179
|
done = true;
|
|
162
180
|
stopTyping();
|
|
163
181
|
clearDraftTimer();
|
|
164
|
-
yield this.
|
|
182
|
+
yield this.sendFinalMessage(chatId, text || event.text);
|
|
165
183
|
return;
|
|
166
184
|
}
|
|
167
185
|
if (event.type === "error") {
|
|
@@ -178,28 +196,6 @@ export class TelegramChatSurfaceAdapter {
|
|
|
178
196
|
}),
|
|
179
197
|
};
|
|
180
198
|
}
|
|
181
|
-
resolveAdminUser(input) {
|
|
182
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
183
|
-
var _a, _b;
|
|
184
|
-
const adminUserResourceId = (_a = this.options.adminUserResourceId) !== null && _a !== void 0 ? _a : DEFAULT_ADMIN_USER_RESOURCE_ID;
|
|
185
|
-
const telegramIdField = (_b = this.options.adminUserTelegramIdField) !== null && _b !== void 0 ? _b : DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD;
|
|
186
|
-
const adminUser = yield input.adminforth.resource(adminUserResourceId).get([
|
|
187
|
-
{
|
|
188
|
-
field: telegramIdField,
|
|
189
|
-
operator: AdminForthFilterOperators.EQ,
|
|
190
|
-
value: input.incoming.externalUserId,
|
|
191
|
-
},
|
|
192
|
-
]);
|
|
193
|
-
if (!adminUser) {
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
return {
|
|
197
|
-
pk: adminUser.id,
|
|
198
|
-
username: adminUser[input.adminforth.config.auth.usernameField],
|
|
199
|
-
dbUser: adminUser,
|
|
200
|
-
};
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
199
|
sendMessage(chatId, text) {
|
|
204
200
|
return __awaiter(this, void 0, void 0, function* () {
|
|
205
201
|
if (!text) {
|
|
@@ -223,6 +219,32 @@ export class TelegramChatSurfaceAdapter {
|
|
|
223
219
|
}
|
|
224
220
|
});
|
|
225
221
|
}
|
|
222
|
+
sendFinalMessage(chatId, text) {
|
|
223
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
224
|
+
const renderedMessage = yield renderFinalMessageImages(text);
|
|
225
|
+
yield this.sendMessage(chatId, renderedMessage.text);
|
|
226
|
+
for (const image of renderedMessage.images) {
|
|
227
|
+
yield this.sendPhoto(chatId, image.buffer, image.filename);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
sendPhoto(chatId, png, filename) {
|
|
232
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
233
|
+
const photoBytes = new Uint8Array(png);
|
|
234
|
+
const formData = new FormData();
|
|
235
|
+
formData.append("chat_id", chatId);
|
|
236
|
+
formData.append("photo", new Blob([photoBytes], {
|
|
237
|
+
type: "image/png",
|
|
238
|
+
}), filename);
|
|
239
|
+
const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendPhoto`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
body: formData,
|
|
242
|
+
});
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
throw new Error(`Telegram sendPhoto failed: ${response.status} ${yield response.text()}`);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
226
248
|
sendChatAction(chatId, action) {
|
|
227
249
|
return __awaiter(this, void 0, void 0, function* () {
|
|
228
250
|
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/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ export type AdapterOptions = {
|
|
|
4
4
|
* Telegram bot token from BotFather.
|
|
5
5
|
*/
|
|
6
6
|
botToken: string;
|
|
7
|
+
/**
|
|
8
|
+
* Telegram bot username used to build the AdminForth account-link URL.
|
|
9
|
+
*/
|
|
10
|
+
botUsername?: string;
|
|
7
11
|
/**
|
|
8
12
|
* Optional secret token configured in Telegram setWebhook.
|
|
9
13
|
*/
|
|
@@ -18,14 +22,4 @@ export type AdapterOptions = {
|
|
|
18
22
|
* Default is 650ms.
|
|
19
23
|
*/
|
|
20
24
|
draftUpdateIntervalMs?: number;
|
|
21
|
-
/**
|
|
22
|
-
* AdminForth admin user field that stores Telegram user id.
|
|
23
|
-
* Default is `telegramId`.
|
|
24
|
-
*/
|
|
25
|
-
adminUserTelegramIdField?: string;
|
|
26
|
-
/**
|
|
27
|
-
* AdminForth admin users resource id.
|
|
28
|
-
* Default is `adminuser`.
|
|
29
|
-
*/
|
|
30
|
-
adminUserResourceId?: string;
|
|
31
25
|
};
|
package/index.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
|
-
AdminForthFilterOperators,
|
|
3
|
-
type AdminUser,
|
|
4
2
|
type ChatSurfaceAdapter,
|
|
5
3
|
type ChatSurfaceEventSink,
|
|
6
4
|
type ChatSurfaceIncomingMessage,
|
|
7
5
|
type ChatSurfaceRequestContext,
|
|
8
|
-
type IAdminForth,
|
|
9
6
|
} from "adminforth";
|
|
10
7
|
import { AdapterOptions } from "./types.js";
|
|
8
|
+
import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
|
|
11
9
|
import { randomInt } from "node:crypto";
|
|
12
10
|
export type { AdapterOptions, TelegramStreamingMode } from "./types.js";
|
|
11
|
+
export {
|
|
12
|
+
getFinalMessageStreamPreview,
|
|
13
|
+
renderFinalMessageImages,
|
|
14
|
+
renderHtmlBlockToPng,
|
|
15
|
+
renderTablePng,
|
|
16
|
+
renderVegaLitePng,
|
|
17
|
+
type RenderedMessage,
|
|
18
|
+
type RenderedMessageImage,
|
|
19
|
+
type RenderTableColumn,
|
|
20
|
+
type RenderTablePngInput,
|
|
21
|
+
type VegaLiteSpec,
|
|
22
|
+
} from "./renderers.js";
|
|
13
23
|
|
|
14
24
|
type TelegramUpdate = {
|
|
15
25
|
message?: {
|
|
@@ -27,14 +37,20 @@ type TelegramUpdate = {
|
|
|
27
37
|
};
|
|
28
38
|
};
|
|
29
39
|
|
|
40
|
+
type ChatSurfaceConnectAction = {
|
|
41
|
+
type: "url";
|
|
42
|
+
label: string;
|
|
43
|
+
url: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
30
46
|
const TELEGRAM_API_BASE_URL = "https://api.telegram.org";
|
|
31
47
|
const TELEGRAM_SECRET_HEADER = "x-telegram-bot-api-secret-token";
|
|
32
48
|
const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
|
|
33
49
|
const TELEGRAM_DRAFT_MAX_LENGTH = 4096;
|
|
34
50
|
const DEFAULT_DRAFT_UPDATE_INTERVAL_MS = 650;
|
|
35
51
|
const DEFAULT_TYPING_INTERVAL_MS = 4000;
|
|
36
|
-
const
|
|
37
|
-
const
|
|
52
|
+
const TELEGRAM_START_COMMAND_PREFIX = "/start";
|
|
53
|
+
const TELEGRAM_COMMAND_PARTS_RE = /\s+/;
|
|
38
54
|
|
|
39
55
|
function createTelegramDraftId() {
|
|
40
56
|
return randomInt(1, 2147483647);
|
|
@@ -77,10 +93,29 @@ function splitTelegramMessage(text: string) {
|
|
|
77
93
|
return chunks;
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
function parseTelegramStartPayload(text: string) {
|
|
97
|
+
const [command, ...payloadParts] = text.trim().split(TELEGRAM_COMMAND_PARTS_RE);
|
|
98
|
+
|
|
99
|
+
if (command !== TELEGRAM_START_COMMAND_PREFIX && !command.startsWith(`${TELEGRAM_START_COMMAND_PREFIX}@`)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return payloadParts.join(" ") || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
80
106
|
export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
81
107
|
name = "telegram";
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
createConnectAction?: (input: { token: string }) => ChatSurfaceConnectAction;
|
|
109
|
+
|
|
110
|
+
constructor(private options: AdapterOptions) {
|
|
111
|
+
if (options.botUsername) {
|
|
112
|
+
this.createConnectAction = ({ token }) => ({
|
|
113
|
+
type: "url",
|
|
114
|
+
label: "Connect Telegram",
|
|
115
|
+
url: `https://t.me/${options.botUsername}?start=${encodeURIComponent(token)}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
84
119
|
|
|
85
120
|
validate() {
|
|
86
121
|
if (!this.options.botToken) {
|
|
@@ -105,6 +140,8 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
105
140
|
return null;
|
|
106
141
|
}
|
|
107
142
|
|
|
143
|
+
const startPayload = parseTelegramStartPayload(text);
|
|
144
|
+
|
|
108
145
|
return {
|
|
109
146
|
surface: this.name,
|
|
110
147
|
prompt: text,
|
|
@@ -112,6 +149,7 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
112
149
|
externalUserId: String(userId),
|
|
113
150
|
userTimeZone: "UTC",
|
|
114
151
|
metadata: {
|
|
152
|
+
startPayload,
|
|
115
153
|
telegramUpdate: update,
|
|
116
154
|
},
|
|
117
155
|
};
|
|
@@ -165,16 +203,18 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
165
203
|
};
|
|
166
204
|
|
|
167
205
|
const flushDraft = async () => {
|
|
206
|
+
const draftPreviewText = getFinalMessageStreamPreview(text);
|
|
207
|
+
|
|
168
208
|
if (
|
|
169
209
|
closed ||
|
|
170
210
|
done ||
|
|
171
211
|
streamingMode !== "draft" ||
|
|
172
|
-
!
|
|
212
|
+
!draftPreviewText
|
|
173
213
|
) {
|
|
174
214
|
return;
|
|
175
215
|
}
|
|
176
216
|
|
|
177
|
-
const draftText = truncateTelegramDraft(
|
|
217
|
+
const draftText = truncateTelegramDraft(draftPreviewText);
|
|
178
218
|
|
|
179
219
|
if (draftText === lastDraftText) {
|
|
180
220
|
return;
|
|
@@ -225,7 +265,7 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
225
265
|
stopTyping();
|
|
226
266
|
clearDraftTimer();
|
|
227
267
|
|
|
228
|
-
await this.
|
|
268
|
+
await this.sendFinalMessage(
|
|
229
269
|
chatId,
|
|
230
270
|
text || event.text,
|
|
231
271
|
);
|
|
@@ -252,31 +292,6 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
|
|
|
252
292
|
};
|
|
253
293
|
}
|
|
254
294
|
|
|
255
|
-
async resolveAdminUser(input: {
|
|
256
|
-
adminforth: IAdminForth;
|
|
257
|
-
incoming: ChatSurfaceIncomingMessage;
|
|
258
|
-
}): Promise<AdminUser | null> {
|
|
259
|
-
const adminUserResourceId = this.options.adminUserResourceId ?? DEFAULT_ADMIN_USER_RESOURCE_ID;
|
|
260
|
-
const telegramIdField = this.options.adminUserTelegramIdField ?? DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD;
|
|
261
|
-
const adminUser = await input.adminforth.resource(adminUserResourceId).get([
|
|
262
|
-
{
|
|
263
|
-
field: telegramIdField,
|
|
264
|
-
operator: AdminForthFilterOperators.EQ,
|
|
265
|
-
value: input.incoming.externalUserId,
|
|
266
|
-
},
|
|
267
|
-
]);
|
|
268
|
-
|
|
269
|
-
if (!adminUser) {
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return {
|
|
274
|
-
pk: adminUser.id,
|
|
275
|
-
username: adminUser[input.adminforth.config.auth!.usernameField],
|
|
276
|
-
dbUser: adminUser,
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
|
|
280
295
|
private async sendMessage(chatId: string, text: string) {
|
|
281
296
|
if (!text) {
|
|
282
297
|
return;
|
|
@@ -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.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -29,9 +29,12 @@
|
|
|
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
|
-
"adminforth": ">=2.
|
|
37
|
+
"adminforth": ">=2.60.0"
|
|
35
38
|
},
|
|
36
39
|
"release": {
|
|
37
40
|
"plugins": [
|
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
|
+
}
|
package/types.ts
CHANGED
|
@@ -6,6 +6,11 @@ export type AdapterOptions = {
|
|
|
6
6
|
*/
|
|
7
7
|
botToken: string;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Telegram bot username used to build the AdminForth account-link URL.
|
|
11
|
+
*/
|
|
12
|
+
botUsername?: string;
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* Optional secret token configured in Telegram setWebhook.
|
|
11
16
|
*/
|
|
@@ -22,16 +27,4 @@ export type AdapterOptions = {
|
|
|
22
27
|
* Default is 650ms.
|
|
23
28
|
*/
|
|
24
29
|
draftUpdateIntervalMs?: number;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* AdminForth admin user field that stores Telegram user id.
|
|
28
|
-
* Default is `telegramId`.
|
|
29
|
-
*/
|
|
30
|
-
adminUserTelegramIdField?: string;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* AdminForth admin users resource id.
|
|
34
|
-
* Default is `adminuser`.
|
|
35
|
-
*/
|
|
36
|
-
adminUserResourceId?: string;
|
|
37
30
|
};
|