@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 +42 -5
- package/dist/index.js +126 -67
- package/dist/types.d.ts +4 -10
- package/index.ts +195 -78
- package/package.json +2 -3
- package/types.ts +5 -12
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
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
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
|
|
21
|
-
const
|
|
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
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
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:
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
322
|
+
throw new Error(`Telegram ${method} failed: ${response.status} ${yield response.text()}`);
|
|
252
323
|
}
|
|
253
324
|
});
|
|
254
325
|
}
|
|
255
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
50
|
-
const
|
|
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
|
-
|
|
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
|
|
114
|
-
const
|
|
115
|
-
const
|
|
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 (!
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
|
473
|
+
throw new Error(`Telegram ${method} failed: ${response.status} ${await response.text()}`);
|
|
344
474
|
}
|
|
345
475
|
}
|
|
346
476
|
|
|
347
|
-
private async
|
|
348
|
-
const response = await fetch(`${TELEGRAM_API_BASE_URL}/bot${this.options.botToken}
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
};
|