@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 CHANGED
@@ -1,6 +1,7 @@
1
- import { type AdminUser, type ChatSurfaceAdapter, type ChatSurfaceEventSink, type ChatSurfaceIncomingMessage, type ChatSurfaceRequestContext, type IAdminForth } from "adminforth";
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 { AdminForthFilterOperators, } from "adminforth";
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 DEFAULT_ADMIN_USER_RESOURCE_ID = "adminuser";
19
- const DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD = "telegramId";
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
- !text) {
141
+ !draftPreviewText) {
124
142
  return;
125
143
  }
126
- const draftText = truncateTelegramDraft(text);
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.sendMessage(chatId, text || event.text);
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
+ "&": "&amp;",
19
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ '"': "&quot;",
22
+ "'": "&#39;",
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 DEFAULT_ADMIN_USER_RESOURCE_ID = "adminuser";
37
- const DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD = "telegramId";
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
- constructor(private options: AdapterOptions) {}
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
- !text
212
+ !draftPreviewText
173
213
  ) {
174
214
  return;
175
215
  }
176
216
 
177
- const draftText = truncateTelegramDraft(text);
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.sendMessage(
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.0.0",
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.59.0"
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
+ "&": "&amp;",
53
+ "<": "&lt;",
54
+ ">": "&gt;",
55
+ '"': "&quot;",
56
+ "'": "&#39;",
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
  };