@adminforth/chat-surface-adapter-telegram 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,10 +1,20 @@
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
4
  export { getFinalMessageStreamPreview, renderFinalMessageImages, renderHtmlBlockToPng, renderTablePng, renderVegaLitePng, type RenderedMessage, type RenderedMessageImage, type RenderTableColumn, type RenderTablePngInput, type VegaLiteSpec, } from "./renderers.js";
5
5
  type TelegramUpdate = {
6
6
  message?: {
7
7
  text?: string;
8
+ caption?: string;
9
+ voice?: {
10
+ file_id?: string;
11
+ mime_type?: string;
12
+ };
13
+ audio?: {
14
+ file_id?: string;
15
+ file_name?: string;
16
+ mime_type?: string;
17
+ };
8
18
  chat?: {
9
19
  id?: number | string;
10
20
  };
@@ -17,9 +27,17 @@ type TelegramUpdate = {
17
27
  };
18
28
  };
19
29
  };
30
+ type ChatSurfaceConnectAction = {
31
+ type: "url";
32
+ label: string;
33
+ url: string;
34
+ };
20
35
  export declare class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
21
36
  private options;
22
37
  name: string;
38
+ createConnectAction?: (input: {
39
+ token: string;
40
+ }) => ChatSurfaceConnectAction;
23
41
  constructor(options: AdapterOptions);
24
42
  validate(): void;
25
43
  parseIncomingMessage(ctx: ChatSurfaceRequestContext): Promise<{
@@ -29,18 +47,37 @@ export declare class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
29
47
  externalUserId: string;
30
48
  userTimeZone: string;
31
49
  metadata: {
50
+ isStartCommand: boolean;
51
+ startPayload: string | null;
52
+ telegramUpdate: TelegramUpdate;
53
+ };
54
+ audio?: undefined;
55
+ } | {
56
+ surface: string;
57
+ prompt: string;
58
+ audio: {
59
+ buffer: Buffer<ArrayBuffer>;
60
+ filename: string;
61
+ mimeType: string;
62
+ };
63
+ externalConversationId: string;
64
+ externalUserId: string;
65
+ userTimeZone: string;
66
+ metadata: {
67
+ isStartCommand: boolean;
68
+ startPayload: string | null;
32
69
  telegramUpdate: TelegramUpdate;
33
70
  };
34
71
  } | null>;
35
72
  createEventSink(ctx: ChatSurfaceRequestContext, incoming: ChatSurfaceIncomingMessage): ChatSurfaceEventSink;
36
- resolveAdminUser(input: {
37
- adminforth: IAdminForth;
38
- incoming: ChatSurfaceIncomingMessage;
39
- }): Promise<AdminUser | null>;
40
73
  private sendMessage;
41
74
  private sendFinalMessage;
42
75
  private sendPhoto;
43
76
  private sendChatAction;
77
+ private downloadTelegramFile;
78
+ private sendAudioFile;
79
+ private telegramMultipart;
80
+ private telegramJson;
44
81
  private sendMessageDraft;
45
82
  }
46
83
  export default TelegramChatSurfaceAdapter;
package/dist/index.js CHANGED
@@ -7,7 +7,6 @@ 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";
11
10
  import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
12
11
  import { randomInt } from "node:crypto";
13
12
  export { getFinalMessageStreamPreview, renderFinalMessageImages, renderHtmlBlockToPng, renderTablePng, renderVegaLitePng, } from "./renderers.js";
@@ -17,8 +16,8 @@ const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
17
16
  const TELEGRAM_DRAFT_MAX_LENGTH = 4096;
18
17
  const DEFAULT_DRAFT_UPDATE_INTERVAL_MS = 650;
19
18
  const DEFAULT_TYPING_INTERVAL_MS = 4000;
20
- const DEFAULT_ADMIN_USER_RESOURCE_ID = "adminuser";
21
- const DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD = "telegramId";
19
+ const TELEGRAM_START_COMMAND_PREFIX = "/start";
20
+ const TELEGRAM_COMMAND_PARTS_RE = /\s+/;
22
21
  function createTelegramDraftId() {
23
22
  return randomInt(1, 2147483647);
24
23
  }
@@ -46,10 +45,26 @@ function splitTelegramMessage(text) {
46
45
  }
47
46
  return chunks;
48
47
  }
48
+ function parseTelegramStartCommand(text) {
49
+ const [command, ...payloadParts] = text.trim().split(TELEGRAM_COMMAND_PARTS_RE);
50
+ const isStartCommand = command === TELEGRAM_START_COMMAND_PREFIX ||
51
+ command.startsWith(`${TELEGRAM_START_COMMAND_PREFIX}@`);
52
+ return {
53
+ isStartCommand,
54
+ payload: isStartCommand ? payloadParts.join(" ") || null : null,
55
+ };
56
+ }
49
57
  export class TelegramChatSurfaceAdapter {
50
58
  constructor(options) {
51
59
  this.options = options;
52
60
  this.name = "telegram";
61
+ if (options.botUsername) {
62
+ this.createConnectAction = ({ token }) => ({
63
+ type: "url",
64
+ label: "Connect Telegram",
65
+ url: `https://t.me/${options.botUsername}?start=${encodeURIComponent(token)}`,
66
+ });
67
+ }
53
68
  }
54
69
  validate() {
55
70
  if (!this.options.botToken) {
@@ -58,25 +73,58 @@ export class TelegramChatSurfaceAdapter {
58
73
  }
59
74
  parseIncomingMessage(ctx) {
60
75
  return __awaiter(this, void 0, void 0, function* () {
61
- var _a, _b, _c, _d, _e;
76
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
62
77
  if (this.options.webhookSecret
63
78
  && getHeaderValue(ctx.headers, TELEGRAM_SECRET_HEADER) !== this.options.webhookSecret) {
64
79
  return null;
65
80
  }
66
81
  const update = ctx.body;
67
- const text = (_a = update.message) === null || _a === void 0 ? void 0 : _a.text;
68
- const chatId = (_c = (_b = update.message) === null || _b === void 0 ? void 0 : _b.chat) === null || _c === void 0 ? void 0 : _c.id;
69
- const userId = (_e = (_d = update.message) === null || _d === void 0 ? void 0 : _d.from) === null || _e === void 0 ? void 0 : _e.id;
70
- if (!text || chatId === undefined || userId === undefined) {
82
+ const message = update.message;
83
+ const text = message === null || message === void 0 ? void 0 : message.text;
84
+ const chatId = (_a = message === null || message === void 0 ? void 0 : message.chat) === null || _a === void 0 ? void 0 : _a.id;
85
+ const userId = (_b = message === null || message === void 0 ? void 0 : message.from) === null || _b === void 0 ? void 0 : _b.id;
86
+ if (chatId === undefined || userId === undefined) {
87
+ return null;
88
+ }
89
+ const startCommand = text
90
+ ? parseTelegramStartCommand(text)
91
+ : { isStartCommand: false, payload: null };
92
+ if (text) {
93
+ return {
94
+ surface: this.name,
95
+ prompt: text,
96
+ externalConversationId: String(chatId),
97
+ externalUserId: String(userId),
98
+ userTimeZone: "UTC",
99
+ metadata: {
100
+ isStartCommand: startCommand.isStartCommand,
101
+ startPayload: startCommand.payload,
102
+ telegramUpdate: update,
103
+ },
104
+ };
105
+ }
106
+ const voiceFileId = (_c = message === null || message === void 0 ? void 0 : message.voice) === null || _c === void 0 ? void 0 : _c.file_id;
107
+ const audioFileId = (_d = message === null || message === void 0 ? void 0 : message.audio) === null || _d === void 0 ? void 0 : _d.file_id;
108
+ const fileId = voiceFileId !== null && voiceFileId !== void 0 ? voiceFileId : audioFileId;
109
+ if (!fileId) {
71
110
  return null;
72
111
  }
112
+ const audio = yield this.downloadTelegramFile({
113
+ fileId,
114
+ filename: (_f = (_e = message === null || message === void 0 ? void 0 : message.audio) === null || _e === void 0 ? void 0 : _e.file_name) !== null && _f !== void 0 ? _f : (voiceFileId ? "telegram-voice.ogg" : "telegram-audio"),
115
+ mimeType: (_k = (_h = (_g = message === null || message === void 0 ? void 0 : message.voice) === null || _g === void 0 ? void 0 : _g.mime_type) !== null && _h !== void 0 ? _h : (_j = message === null || message === void 0 ? void 0 : message.audio) === null || _j === void 0 ? void 0 : _j.mime_type) !== null && _k !== void 0 ? _k : "application/octet-stream",
116
+ abortSignal: ctx.abortSignal,
117
+ });
73
118
  return {
74
119
  surface: this.name,
75
- prompt: text,
120
+ prompt: (_l = message === null || message === void 0 ? void 0 : message.caption) !== null && _l !== void 0 ? _l : "",
121
+ audio,
76
122
  externalConversationId: String(chatId),
77
123
  externalUserId: String(userId),
78
124
  userTimeZone: "UTC",
79
125
  metadata: {
126
+ isStartCommand: startCommand.isStartCommand,
127
+ startPayload: startCommand.payload,
80
128
  telegramUpdate: update,
81
129
  },
82
130
  };
@@ -167,6 +215,10 @@ export class TelegramChatSurfaceAdapter {
167
215
  yield this.sendFinalMessage(chatId, text || event.text);
168
216
  return;
169
217
  }
218
+ if (event.type === "audio") {
219
+ yield this.sendAudioFile(chatId, event.audio, event.filename, event.mimeType);
220
+ return;
221
+ }
170
222
  if (event.type === "error") {
171
223
  done = true;
172
224
  stopTyping();
@@ -181,48 +233,17 @@ export class TelegramChatSurfaceAdapter {
181
233
  }),
182
234
  };
183
235
  }
184
- resolveAdminUser(input) {
185
- return __awaiter(this, void 0, void 0, function* () {
186
- var _a, _b;
187
- const adminUserResourceId = (_a = this.options.adminUserResourceId) !== null && _a !== void 0 ? _a : DEFAULT_ADMIN_USER_RESOURCE_ID;
188
- const telegramIdField = (_b = this.options.adminUserTelegramIdField) !== null && _b !== void 0 ? _b : DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD;
189
- const adminUser = yield input.adminforth.resource(adminUserResourceId).get([
190
- {
191
- field: telegramIdField,
192
- operator: AdminForthFilterOperators.EQ,
193
- value: input.incoming.externalUserId,
194
- },
195
- ]);
196
- if (!adminUser) {
197
- return null;
198
- }
199
- return {
200
- pk: adminUser.id,
201
- username: adminUser[input.adminforth.config.auth.usernameField],
202
- dbUser: adminUser,
203
- };
204
- });
205
- }
206
236
  sendMessage(chatId, text) {
207
237
  return __awaiter(this, void 0, void 0, function* () {
208
238
  if (!text) {
209
239
  return;
210
240
  }
211
241
  for (const chunk of splitTelegramMessage(text)) {
212
- const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendMessage`, {
213
- method: "POST",
214
- headers: {
215
- "Content-Type": "application/json",
216
- },
217
- body: JSON.stringify({
218
- chat_id: chatId,
219
- text: chunk,
220
- parse_mode: "Markdown",
221
- }),
242
+ yield this.telegramJson("sendMessage", {
243
+ chat_id: chatId,
244
+ text: chunk,
245
+ parse_mode: "Markdown",
222
246
  });
223
- if (!response.ok) {
224
- throw new Error(`Telegram sendMessage failed: ${response.status} ${yield response.text()}`);
225
- }
226
247
  }
227
248
  });
228
249
  }
@@ -243,49 +264,87 @@ export class TelegramChatSurfaceAdapter {
243
264
  formData.append("photo", new Blob([photoBytes], {
244
265
  type: "image/png",
245
266
  }), filename);
246
- const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendPhoto`, {
267
+ yield this.telegramMultipart("sendPhoto", formData);
268
+ });
269
+ }
270
+ sendChatAction(chatId, action) {
271
+ return __awaiter(this, void 0, void 0, function* () {
272
+ yield this.telegramJson("sendChatAction", {
273
+ chat_id: chatId,
274
+ action,
275
+ });
276
+ });
277
+ }
278
+ downloadTelegramFile(input) {
279
+ return __awaiter(this, void 0, void 0, function* () {
280
+ var _a, _b;
281
+ const fileResponse = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/getFile?file_id=${encodeURIComponent(input.fileId)}`, { signal: input.abortSignal });
282
+ if (!fileResponse.ok) {
283
+ throw new Error(`Telegram getFile failed: ${fileResponse.status} ${yield fileResponse.text()}`);
284
+ }
285
+ const fileData = yield fileResponse.json();
286
+ const filePath = (_a = fileData.result) === null || _a === void 0 ? void 0 : _a.file_path;
287
+ if (!fileData.ok || !filePath) {
288
+ throw new Error(`Telegram getFile failed: ${(_b = fileData.description) !== null && _b !== void 0 ? _b : "file_path is missing"}`);
289
+ }
290
+ const downloadResponse = yield fetch(`${TELEGRAM_API_BASE_URL}/file/bot${this.options.botToken}/${filePath}`, { signal: input.abortSignal });
291
+ if (!downloadResponse.ok) {
292
+ throw new Error(`Telegram file download failed: ${downloadResponse.status} ${yield downloadResponse.text()}`);
293
+ }
294
+ return {
295
+ buffer: Buffer.from(yield downloadResponse.arrayBuffer()),
296
+ filename: input.filename,
297
+ mimeType: input.mimeType,
298
+ };
299
+ });
300
+ }
301
+ sendAudioFile(chatId, audio, filename, mimeType) {
302
+ return __awaiter(this, void 0, void 0, function* () {
303
+ const sendAsVoice = ["audio/ogg", "audio/opus"].includes(mimeType) || filename.endsWith(".ogg") || filename.endsWith(".opus");
304
+ const method = sendAsVoice ? "sendVoice" : "sendAudio";
305
+ const fieldName = sendAsVoice ? "voice" : "audio";
306
+ const audioBytes = new Uint8Array(audio);
307
+ const formData = new FormData();
308
+ formData.append("chat_id", chatId);
309
+ formData.append(fieldName, new Blob([audioBytes], {
310
+ type: mimeType,
311
+ }), filename);
312
+ yield this.telegramMultipart(method, formData);
313
+ });
314
+ }
315
+ telegramMultipart(method, formData) {
316
+ return __awaiter(this, void 0, void 0, function* () {
317
+ const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/${method}`, {
247
318
  method: "POST",
248
319
  body: formData,
249
320
  });
250
321
  if (!response.ok) {
251
- throw new Error(`Telegram sendPhoto failed: ${response.status} ${yield response.text()}`);
322
+ throw new Error(`Telegram ${method} failed: ${response.status} ${yield response.text()}`);
252
323
  }
253
324
  });
254
325
  }
255
- sendChatAction(chatId, action) {
326
+ telegramJson(method, body) {
256
327
  return __awaiter(this, void 0, void 0, function* () {
257
- const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendChatAction`, {
328
+ const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/${method}`, {
258
329
  method: "POST",
259
330
  headers: {
260
331
  "Content-Type": "application/json",
261
332
  },
262
- body: JSON.stringify({
263
- chat_id: chatId,
264
- action,
265
- }),
333
+ body: JSON.stringify(body),
266
334
  });
267
335
  if (!response.ok) {
268
- throw new Error(`Telegram sendChatAction failed: ${response.status} ${yield response.text()}`);
336
+ throw new Error(`Telegram ${method} failed: ${response.status} ${yield response.text()}`);
269
337
  }
270
338
  });
271
339
  }
272
340
  sendMessageDraft(input) {
273
341
  return __awaiter(this, void 0, void 0, function* () {
274
- const response = yield fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendMessageDraft`, {
275
- method: "POST",
276
- headers: {
277
- "Content-Type": "application/json",
278
- },
279
- body: JSON.stringify({
280
- chat_id: Number(input.chatId),
281
- draft_id: input.draftId,
282
- text: input.text,
283
- parse_mode: input.parseMode,
284
- }),
342
+ yield this.telegramJson("sendMessageDraft", {
343
+ chat_id: Number(input.chatId),
344
+ draft_id: input.draftId,
345
+ text: input.text,
346
+ parse_mode: input.parseMode,
285
347
  });
286
- if (!response.ok) {
287
- throw new Error(`Telegram sendMessageDraft failed: ${response.status} ${yield response.text()}`);
288
- }
289
348
  });
290
349
  }
291
350
  }
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,11 +1,9 @@
1
1
  import {
2
- AdminForthFilterOperators,
3
- type AdminUser,
4
2
  type ChatSurfaceAdapter,
3
+ type ChatSurfaceEvent,
5
4
  type ChatSurfaceEventSink,
6
5
  type ChatSurfaceIncomingMessage,
7
6
  type ChatSurfaceRequestContext,
8
- type IAdminForth,
9
7
  } from "adminforth";
10
8
  import { AdapterOptions } from "./types.js";
11
9
  import { getFinalMessageStreamPreview, renderFinalMessageImages } from "./renderers.js";
@@ -27,6 +25,16 @@ export {
27
25
  type TelegramUpdate = {
28
26
  message?: {
29
27
  text?: string;
28
+ caption?: string;
29
+ voice?: {
30
+ file_id?: string;
31
+ mime_type?: string;
32
+ };
33
+ audio?: {
34
+ file_id?: string;
35
+ file_name?: string;
36
+ mime_type?: string;
37
+ };
30
38
  chat?: {
31
39
  id?: number | string;
32
40
  };
@@ -40,14 +48,37 @@ type TelegramUpdate = {
40
48
  };
41
49
  };
42
50
 
51
+ type TelegramGetFileResponse = {
52
+ ok: boolean;
53
+ result?: {
54
+ file_path?: string;
55
+ };
56
+ description?: string;
57
+ };
58
+
59
+ type ChatSurfaceConnectAction = {
60
+ type: "url";
61
+ label: string;
62
+ url: string;
63
+ };
64
+
65
+ type TelegramChatSurfaceEvent =
66
+ | ChatSurfaceEvent
67
+ | {
68
+ type: "audio";
69
+ audio: Buffer;
70
+ filename: string;
71
+ mimeType: string;
72
+ };
73
+
43
74
  const TELEGRAM_API_BASE_URL = "https://api.telegram.org";
44
75
  const TELEGRAM_SECRET_HEADER = "x-telegram-bot-api-secret-token";
45
76
  const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
46
77
  const TELEGRAM_DRAFT_MAX_LENGTH = 4096;
47
78
  const DEFAULT_DRAFT_UPDATE_INTERVAL_MS = 650;
48
79
  const DEFAULT_TYPING_INTERVAL_MS = 4000;
49
- const DEFAULT_ADMIN_USER_RESOURCE_ID = "adminuser";
50
- const DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD = "telegramId";
80
+ const TELEGRAM_START_COMMAND_PREFIX = "/start";
81
+ const TELEGRAM_COMMAND_PARTS_RE = /\s+/;
51
82
 
52
83
  function createTelegramDraftId() {
53
84
  return randomInt(1, 2147483647);
@@ -90,10 +121,31 @@ function splitTelegramMessage(text: string) {
90
121
  return chunks;
91
122
  }
92
123
 
124
+ function parseTelegramStartCommand(text: string) {
125
+ const [command, ...payloadParts] = text.trim().split(TELEGRAM_COMMAND_PARTS_RE);
126
+ const isStartCommand =
127
+ command === TELEGRAM_START_COMMAND_PREFIX ||
128
+ command.startsWith(`${TELEGRAM_START_COMMAND_PREFIX}@`);
129
+
130
+ return {
131
+ isStartCommand,
132
+ payload: isStartCommand ? payloadParts.join(" ") || null : null,
133
+ };
134
+ }
135
+
93
136
  export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
94
137
  name = "telegram";
95
-
96
- constructor(private options: AdapterOptions) {}
138
+ createConnectAction?: (input: { token: string }) => ChatSurfaceConnectAction;
139
+
140
+ constructor(private options: AdapterOptions) {
141
+ if (options.botUsername) {
142
+ this.createConnectAction = ({ token }) => ({
143
+ type: "url",
144
+ label: "Connect Telegram",
145
+ url: `https://t.me/${options.botUsername}?start=${encodeURIComponent(token)}`,
146
+ });
147
+ }
148
+ }
97
149
 
98
150
  validate() {
99
151
  if (!this.options.botToken) {
@@ -110,21 +162,59 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
110
162
  }
111
163
 
112
164
  const update = ctx.body as TelegramUpdate;
113
- const text = update.message?.text;
114
- const chatId = update.message?.chat?.id;
115
- const userId = update.message?.from?.id;
165
+ const message = update.message;
166
+ const text = message?.text;
167
+ const chatId = message?.chat?.id;
168
+ const userId = message?.from?.id;
169
+
170
+ if (chatId === undefined || userId === undefined) {
171
+ return null;
172
+ }
173
+
174
+ const startCommand = text
175
+ ? parseTelegramStartCommand(text)
176
+ : { isStartCommand: false, payload: null };
177
+
178
+ if (text) {
179
+ return {
180
+ surface: this.name,
181
+ prompt: text,
182
+ externalConversationId: String(chatId),
183
+ externalUserId: String(userId),
184
+ userTimeZone: "UTC",
185
+ metadata: {
186
+ isStartCommand: startCommand.isStartCommand,
187
+ startPayload: startCommand.payload,
188
+ telegramUpdate: update,
189
+ },
190
+ };
191
+ }
192
+
193
+ const voiceFileId = message?.voice?.file_id;
194
+ const audioFileId = message?.audio?.file_id;
195
+ const fileId = voiceFileId ?? audioFileId;
116
196
 
117
- if (!text || chatId === undefined || userId === undefined) {
197
+ if (!fileId) {
118
198
  return null;
119
199
  }
120
200
 
201
+ const audio = await this.downloadTelegramFile({
202
+ fileId,
203
+ filename: message?.audio?.file_name ?? (voiceFileId ? "telegram-voice.ogg" : "telegram-audio"),
204
+ mimeType: message?.voice?.mime_type ?? message?.audio?.mime_type ?? "application/octet-stream",
205
+ abortSignal: ctx.abortSignal,
206
+ });
207
+
121
208
  return {
122
209
  surface: this.name,
123
- prompt: text,
210
+ prompt: message?.caption ?? "",
211
+ audio,
124
212
  externalConversationId: String(chatId),
125
213
  externalUserId: String(userId),
126
214
  userTimeZone: "UTC",
127
215
  metadata: {
216
+ isStartCommand: startCommand.isStartCommand,
217
+ startPayload: startCommand.payload,
128
218
  telegramUpdate: update,
129
219
  },
130
220
  };
@@ -220,7 +310,7 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
220
310
  startTyping();
221
311
 
222
312
  return {
223
- emit: async (event) => {
313
+ emit: async (event: TelegramChatSurfaceEvent) => {
224
314
  if (closed) {
225
315
  return;
226
316
  }
@@ -240,9 +330,16 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
240
330
  stopTyping();
241
331
  clearDraftTimer();
242
332
 
243
- await this.sendFinalMessage(
333
+ await this.sendFinalMessage(chatId, text || event.text);
334
+ return;
335
+ }
336
+
337
+ if (event.type === "audio") {
338
+ await this.sendAudioFile(
244
339
  chatId,
245
- text || event.text,
340
+ event.audio,
341
+ event.filename,
342
+ event.mimeType,
246
343
  );
247
344
  return;
248
345
  }
@@ -267,52 +364,17 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
267
364
  };
268
365
  }
269
366
 
270
- async resolveAdminUser(input: {
271
- adminforth: IAdminForth;
272
- incoming: ChatSurfaceIncomingMessage;
273
- }): Promise<AdminUser | null> {
274
- const adminUserResourceId = this.options.adminUserResourceId ?? DEFAULT_ADMIN_USER_RESOURCE_ID;
275
- const telegramIdField = this.options.adminUserTelegramIdField ?? DEFAULT_ADMIN_USER_TELEGRAM_ID_FIELD;
276
- const adminUser = await input.adminforth.resource(adminUserResourceId).get([
277
- {
278
- field: telegramIdField,
279
- operator: AdminForthFilterOperators.EQ,
280
- value: input.incoming.externalUserId,
281
- },
282
- ]);
283
-
284
- if (!adminUser) {
285
- return null;
286
- }
287
-
288
- return {
289
- pk: adminUser.id,
290
- username: adminUser[input.adminforth.config.auth!.usernameField],
291
- dbUser: adminUser,
292
- };
293
- }
294
-
295
367
  private async sendMessage(chatId: string, text: string) {
296
368
  if (!text) {
297
369
  return;
298
370
  }
299
371
 
300
372
  for (const chunk of splitTelegramMessage(text)) {
301
- const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendMessage`, {
302
- method: "POST",
303
- headers: {
304
- "Content-Type": "application/json",
305
- },
306
- body: JSON.stringify({
307
- chat_id: chatId,
308
- text: chunk,
309
- parse_mode: "Markdown",
310
- }),
373
+ await this.telegramJson("sendMessage", {
374
+ chat_id: chatId,
375
+ text: chunk,
376
+ parse_mode: "Markdown",
311
377
  });
312
-
313
- if (!response.ok) {
314
- throw new Error(`Telegram sendMessage failed: ${response.status} ${await response.text()}`);
315
- }
316
378
  }
317
379
  }
318
380
 
@@ -334,30 +396,95 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
334
396
  type: "image/png",
335
397
  }), filename);
336
398
 
337
- const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendPhoto`, {
399
+ await this.telegramMultipart("sendPhoto", formData);
400
+ }
401
+
402
+ private async sendChatAction(chatId: string, action: "typing" | "upload_voice" | "upload_audio") {
403
+ await this.telegramJson("sendChatAction", {
404
+ chat_id: chatId,
405
+ action,
406
+ });
407
+ }
408
+
409
+ private async downloadTelegramFile(input: {
410
+ fileId: string;
411
+ filename: string;
412
+ mimeType: string;
413
+ abortSignal: AbortSignal;
414
+ }) {
415
+ const fileResponse = await fetch(
416
+ `${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/getFile?file_id=${encodeURIComponent(input.fileId)}`,
417
+ { signal: input.abortSignal },
418
+ );
419
+
420
+ if (!fileResponse.ok) {
421
+ throw new Error(`Telegram getFile failed: ${fileResponse.status} ${await fileResponse.text()}`);
422
+ }
423
+
424
+ const fileData = await fileResponse.json() as TelegramGetFileResponse;
425
+ const filePath = fileData.result?.file_path;
426
+
427
+ if (!fileData.ok || !filePath) {
428
+ throw new Error(`Telegram getFile failed: ${fileData.description ?? "file_path is missing"}`);
429
+ }
430
+
431
+ const downloadResponse = await fetch(
432
+ `${TELEGRAM_API_BASE_URL}/file/bot${this.options.botToken}/${filePath}`,
433
+ { signal: input.abortSignal },
434
+ );
435
+
436
+ if (!downloadResponse.ok) {
437
+ throw new Error(`Telegram file download failed: ${downloadResponse.status} ${await downloadResponse.text()}`);
438
+ }
439
+
440
+ return {
441
+ buffer: Buffer.from(await downloadResponse.arrayBuffer()),
442
+ filename: input.filename,
443
+ mimeType: input.mimeType,
444
+ };
445
+ }
446
+
447
+ private async sendAudioFile(
448
+ chatId: string,
449
+ audio: Buffer,
450
+ filename: string,
451
+ mimeType: string,
452
+ ) {
453
+ const sendAsVoice = ["audio/ogg", "audio/opus"].includes(mimeType) || filename.endsWith(".ogg") || filename.endsWith(".opus");
454
+ const method = sendAsVoice ? "sendVoice" : "sendAudio";
455
+ const fieldName = sendAsVoice ? "voice" : "audio";
456
+ const audioBytes = new Uint8Array(audio);
457
+ const formData = new FormData();
458
+ formData.append("chat_id", chatId);
459
+ formData.append(fieldName, new Blob([audioBytes], {
460
+ type: mimeType,
461
+ }), filename);
462
+
463
+ await this.telegramMultipart(method, formData);
464
+ }
465
+
466
+ private async telegramMultipart(method: string, formData: FormData) {
467
+ const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/${method}`, {
338
468
  method: "POST",
339
469
  body: formData,
340
470
  });
341
471
 
342
472
  if (!response.ok) {
343
- throw new Error(`Telegram sendPhoto failed: ${response.status} ${await response.text()}`);
473
+ throw new Error(`Telegram ${method} failed: ${response.status} ${await response.text()}`);
344
474
  }
345
475
  }
346
476
 
347
- private async sendChatAction(chatId: string, action: "typing") {
348
- const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendChatAction`, {
477
+ private async telegramJson(method: string, body: unknown) {
478
+ const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/${method}`, {
349
479
  method: "POST",
350
480
  headers: {
351
481
  "Content-Type": "application/json",
352
482
  },
353
- body: JSON.stringify({
354
- chat_id: chatId,
355
- action,
356
- }),
483
+ body: JSON.stringify(body),
357
484
  });
358
485
 
359
486
  if (!response.ok) {
360
- throw new Error(`Telegram sendChatAction failed: ${response.status} ${await response.text()}`);
487
+ throw new Error(`Telegram ${method} failed: ${response.status} ${await response.text()}`);
361
488
  }
362
489
  }
363
490
 
@@ -367,22 +494,12 @@ export class TelegramChatSurfaceAdapter implements ChatSurfaceAdapter {
367
494
  text: string;
368
495
  parseMode?: "Markdown" | "MarkdownV2" | "HTML";
369
496
  }) {
370
- const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}/sendMessageDraft`, {
371
- method: "POST",
372
- headers: {
373
- "Content-Type": "application/json",
374
- },
375
- body: JSON.stringify({
376
- chat_id: Number(input.chatId),
377
- draft_id: input.draftId,
378
- text: input.text,
379
- parse_mode: input.parseMode,
380
- }),
497
+ await this.telegramJson("sendMessageDraft", {
498
+ chat_id: Number(input.chatId),
499
+ draft_id: input.draftId,
500
+ text: input.text,
501
+ parse_mode: input.parseMode,
381
502
  });
382
-
383
- if (!response.ok) {
384
- throw new Error(`Telegram sendMessageDraft failed: ${response.status} ${await response.text()}`);
385
- }
386
503
  }
387
504
  }
388
505
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/chat-surface-adapter-telegram",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -33,8 +33,7 @@
33
33
  "playwright": "^1.57.0"
34
34
  },
35
35
  "peerDependencies": {
36
- "@adminforth/agent": ">=1.0.2",
37
- "adminforth": ">=2.59.0"
36
+ "adminforth": ">=2.60.0"
38
37
  },
39
38
  "release": {
40
39
  "plugins": [
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
  };