@chatman-media/channel-telegram 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/bot-api/adapter.d.ts +43 -0
- package/dist/bot-api/adapter.d.ts.map +1 -0
- package/dist/bot-api/adapter.test.d.ts +2 -0
- package/dist/bot-api/adapter.test.d.ts.map +1 -0
- package/dist/bot-api/client.d.ts +135 -0
- package/dist/bot-api/client.d.ts.map +1 -0
- package/dist/bot-api/types.d.ts +120 -0
- package/dist/bot-api/types.d.ts.map +1 -0
- package/dist/bot-api/update-parser.d.ts +9 -0
- package/dist/bot-api/update-parser.d.ts.map +1 -0
- package/dist/bot-api/update-parser.test.d.ts +2 -0
- package/dist/bot-api/update-parser.test.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33407 -0
- package/dist/userbot/adapter.d.ts +121 -0
- package/dist/userbot/adapter.d.ts.map +1 -0
- package/dist/userbot/login.d.ts +62 -0
- package/dist/userbot/login.d.ts.map +1 -0
- package/package.json +70 -0
- package/src/bot-api/adapter.test.ts +105 -0
- package/src/bot-api/adapter.ts +242 -0
- package/src/bot-api/client.ts +275 -0
- package/src/bot-api/types.ts +134 -0
- package/src/bot-api/update-parser.test.ts +142 -0
- package/src/bot-api/update-parser.ts +112 -0
- package/src/index.ts +38 -0
- package/src/userbot/adapter.ts +517 -0
- package/src/userbot/login.ts +160 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { TgFile, TgReplyMarkup, TgSendMessageResult, TgUser } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type FetchLike = typeof fetch;
|
|
4
|
+
|
|
5
|
+
export interface TelegramClientOptions {
|
|
6
|
+
token: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
fetch?: FetchLike;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class TelegramApiError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
public method: string,
|
|
14
|
+
public statusCode: number,
|
|
15
|
+
public errorCode: number | undefined,
|
|
16
|
+
public description: string,
|
|
17
|
+
) {
|
|
18
|
+
super(`Telegram ${method} failed (${statusCode}): ${description}`);
|
|
19
|
+
this.name = "TelegramApiError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TgResponse<T> {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
result?: T;
|
|
26
|
+
error_code?: number;
|
|
27
|
+
description?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TelegramClient {
|
|
31
|
+
private readonly token: string;
|
|
32
|
+
private readonly baseUrl: string;
|
|
33
|
+
private readonly fetchImpl: FetchLike;
|
|
34
|
+
|
|
35
|
+
constructor(opts: TelegramClientOptions) {
|
|
36
|
+
if (!opts.token) throw new Error("TelegramClient: token is required");
|
|
37
|
+
this.token = opts.token;
|
|
38
|
+
this.baseUrl = opts.baseUrl ?? "https://api.telegram.org";
|
|
39
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
|
43
|
+
const url = `${this.baseUrl}/bot${this.token}/${method}`;
|
|
44
|
+
const res = await this.fetchImpl(url, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
body: JSON.stringify(params),
|
|
48
|
+
});
|
|
49
|
+
let body: TgResponse<T>;
|
|
50
|
+
try {
|
|
51
|
+
body = (await res.json()) as TgResponse<T>;
|
|
52
|
+
} catch {
|
|
53
|
+
throw new TelegramApiError(
|
|
54
|
+
method,
|
|
55
|
+
res.status,
|
|
56
|
+
undefined,
|
|
57
|
+
`non-JSON response (status ${res.status})`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!res.ok || !body.ok || body.result === undefined) {
|
|
61
|
+
throw new TelegramApiError(
|
|
62
|
+
method,
|
|
63
|
+
res.status,
|
|
64
|
+
body.error_code,
|
|
65
|
+
body.description ?? "unknown error",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return body.result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getMe(): Promise<TgUser> {
|
|
72
|
+
return this.call<TgUser>("getMe", {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a file_id to a downloadable path. The path returned is
|
|
77
|
+
* appended to `<baseUrl>/file/bot<token>/...` (NOT the regular
|
|
78
|
+
* `<baseUrl>/bot<token>/...` API endpoint) — see `downloadFile` for
|
|
79
|
+
* the right URL shape.
|
|
80
|
+
*/
|
|
81
|
+
getFile(fileId: string): Promise<TgFile> {
|
|
82
|
+
return this.call<TgFile>("getFile", { file_id: fileId });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Two-step download for any file_id we hold:
|
|
87
|
+
* 1. getFile → returns `file_path`
|
|
88
|
+
* 2. fetch the bytes from `<baseUrl>/file/bot<token>/<file_path>`
|
|
89
|
+
*
|
|
90
|
+
* Returns the raw `Response` so the admin layer can stream the body
|
|
91
|
+
* straight to the browser without buffering. Throws when the file is
|
|
92
|
+
* too large for the Bot Download API (Telegram's ~20 MB cap leaves
|
|
93
|
+
* `file_path` undefined).
|
|
94
|
+
*
|
|
95
|
+
* The token is held privately by the client — the admin endpoint
|
|
96
|
+
* proxies through this helper rather than getting the token directly,
|
|
97
|
+
* so secrets never leave the server.
|
|
98
|
+
*/
|
|
99
|
+
async downloadFile(fileId: string): Promise<Response> {
|
|
100
|
+
const info = await this.getFile(fileId);
|
|
101
|
+
if (!info.file_path) {
|
|
102
|
+
throw new Error(`Telegram file ${fileId} has no file_path (too large?)`);
|
|
103
|
+
}
|
|
104
|
+
const url = `${this.baseUrl}/file/bot${this.token}/${info.file_path}`;
|
|
105
|
+
return this.fetchImpl(url);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
sendMessage(input: {
|
|
109
|
+
chatId: number | string;
|
|
110
|
+
text: string;
|
|
111
|
+
parseMode?: "MarkdownV2" | "HTML" | "Markdown";
|
|
112
|
+
replyMarkup?: TgReplyMarkup;
|
|
113
|
+
disableWebPagePreview?: boolean;
|
|
114
|
+
replyToMessageId?: number;
|
|
115
|
+
}): Promise<TgSendMessageResult> {
|
|
116
|
+
const params: Record<string, unknown> = {
|
|
117
|
+
chat_id: input.chatId,
|
|
118
|
+
text: input.text,
|
|
119
|
+
};
|
|
120
|
+
if (input.parseMode) params.parse_mode = input.parseMode;
|
|
121
|
+
if (input.replyMarkup) params.reply_markup = input.replyMarkup;
|
|
122
|
+
if (input.disableWebPagePreview) params.disable_web_page_preview = input.disableWebPagePreview;
|
|
123
|
+
if (input.replyToMessageId !== undefined) params.reply_to_message_id = input.replyToMessageId;
|
|
124
|
+
return this.call<TgSendMessageResult>("sendMessage", params);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Re-send a photo by file_id. Used by the operator-relay path: when the
|
|
129
|
+
* operator uploads a photo to the ops chat as a reply to a lead card,
|
|
130
|
+
* the webhook hands the largest size's file_id straight to this method
|
|
131
|
+
* — Telegram is happy to re-deliver media we've already seen, no need
|
|
132
|
+
* to download and re-upload bytes.
|
|
133
|
+
*/
|
|
134
|
+
sendPhoto(input: {
|
|
135
|
+
chatId: number | string;
|
|
136
|
+
photoFileId: string;
|
|
137
|
+
caption?: string;
|
|
138
|
+
}): Promise<TgSendMessageResult> {
|
|
139
|
+
const params: Record<string, unknown> = {
|
|
140
|
+
chat_id: input.chatId,
|
|
141
|
+
photo: input.photoFileId,
|
|
142
|
+
};
|
|
143
|
+
if (input.caption) params.caption = input.caption;
|
|
144
|
+
return this.call<TgSendMessageResult>("sendPhoto", params);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
sendVideo(input: {
|
|
148
|
+
chatId: number | string;
|
|
149
|
+
videoFileId: string;
|
|
150
|
+
caption?: string;
|
|
151
|
+
}): Promise<TgSendMessageResult> {
|
|
152
|
+
const params: Record<string, unknown> = {
|
|
153
|
+
chat_id: input.chatId,
|
|
154
|
+
video: input.videoFileId,
|
|
155
|
+
};
|
|
156
|
+
if (input.caption) params.caption = input.caption;
|
|
157
|
+
return this.call<TgSendMessageResult>("sendVideo", params);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Send a video from a local file on disk (not a Telegram `file_id`).
|
|
162
|
+
* Bot API supports this via multipart upload, but our funnel goes through
|
|
163
|
+
* the userbot — implemented as a stub here so the symbol resolves; the
|
|
164
|
+
* real impl lives in `makeUserbotSender` in `userbot.ts` and uses gramjs
|
|
165
|
+
* `sendFile` under the hood.
|
|
166
|
+
*/
|
|
167
|
+
sendLocalVideo(_input: {
|
|
168
|
+
chatId: number | string;
|
|
169
|
+
localFilePath: string;
|
|
170
|
+
caption?: string;
|
|
171
|
+
}): Promise<TgSendMessageResult> {
|
|
172
|
+
throw new Error("sendLocalVideo: Bot API path not implemented — userbot-only feature");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
sendDocument(input: {
|
|
176
|
+
chatId: number | string;
|
|
177
|
+
documentFileId: string;
|
|
178
|
+
caption?: string;
|
|
179
|
+
}): Promise<TgSendMessageResult> {
|
|
180
|
+
const params: Record<string, unknown> = {
|
|
181
|
+
chat_id: input.chatId,
|
|
182
|
+
document: input.documentFileId,
|
|
183
|
+
};
|
|
184
|
+
if (input.caption) params.caption = input.caption;
|
|
185
|
+
return this.call<TgSendMessageResult>("sendDocument", params);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Edit a message we previously sent (commonly the lead card after the
|
|
190
|
+
* operator clicks approve/reject — the card stays in the ops chat with
|
|
191
|
+
* the new state visible). `text` replaces the body; `replyMarkup`
|
|
192
|
+
* replaces the inline keyboard (pass `{}` to remove the buttons
|
|
193
|
+
* entirely on a finalized state).
|
|
194
|
+
*/
|
|
195
|
+
editMessageText(input: {
|
|
196
|
+
chatId: number | string;
|
|
197
|
+
messageId: number;
|
|
198
|
+
text: string;
|
|
199
|
+
parseMode?: "MarkdownV2" | "HTML" | "Markdown";
|
|
200
|
+
replyMarkup?: TgReplyMarkup;
|
|
201
|
+
}): Promise<TgSendMessageResult | true> {
|
|
202
|
+
const params: Record<string, unknown> = {
|
|
203
|
+
chat_id: input.chatId,
|
|
204
|
+
message_id: input.messageId,
|
|
205
|
+
text: input.text,
|
|
206
|
+
};
|
|
207
|
+
if (input.parseMode) params.parse_mode = input.parseMode;
|
|
208
|
+
if (input.replyMarkup) params.reply_markup = input.replyMarkup;
|
|
209
|
+
return this.call<TgSendMessageResult | true>("editMessageText", params);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Delete a message we previously sent. Used by the admin "delete message"
|
|
214
|
+
* action on the Bot-API channel. Telegram lets a bot delete its own
|
|
215
|
+
* outgoing messages in a private chat with no time limit.
|
|
216
|
+
*/
|
|
217
|
+
deleteMessage(input: { chatId: number | string; messageId: number }): Promise<true> {
|
|
218
|
+
return this.call<true>("deleteMessage", {
|
|
219
|
+
chat_id: input.chatId,
|
|
220
|
+
message_id: input.messageId,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Acknowledge an inline-keyboard click. Telegram requires a response
|
|
226
|
+
* within a few seconds or the spinner on the user's button keeps
|
|
227
|
+
* rotating. Pass `text` to flash a small notification ("Approved!").
|
|
228
|
+
*/
|
|
229
|
+
answerCallbackQuery(input: {
|
|
230
|
+
callbackQueryId: string;
|
|
231
|
+
text?: string;
|
|
232
|
+
showAlert?: boolean;
|
|
233
|
+
}): Promise<true> {
|
|
234
|
+
const params: Record<string, unknown> = {
|
|
235
|
+
callback_query_id: input.callbackQueryId,
|
|
236
|
+
};
|
|
237
|
+
if (input.text) params.text = input.text;
|
|
238
|
+
if (input.showAlert) params.show_alert = input.showAlert;
|
|
239
|
+
return this.call<true>("answerCallbackQuery", params);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
sendChatAction(input: {
|
|
243
|
+
chatId: number | string;
|
|
244
|
+
action: "typing" | "upload_photo" | "record_video" | "upload_voice" | "upload_document";
|
|
245
|
+
}): Promise<true> {
|
|
246
|
+
return this.call<true>("sendChatAction", {
|
|
247
|
+
chat_id: input.chatId,
|
|
248
|
+
action: input.action,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
setWebhook(input: {
|
|
253
|
+
url: string;
|
|
254
|
+
secretToken?: string;
|
|
255
|
+
allowedUpdates?: string[];
|
|
256
|
+
dropPendingUpdates?: boolean;
|
|
257
|
+
}): Promise<true> {
|
|
258
|
+
const params: Record<string, unknown> = { url: input.url };
|
|
259
|
+
if (input.secretToken) params.secret_token = input.secretToken;
|
|
260
|
+
if (input.allowedUpdates) params.allowed_updates = input.allowedUpdates;
|
|
261
|
+
if (input.dropPendingUpdates !== undefined)
|
|
262
|
+
params.drop_pending_updates = input.dropPendingUpdates;
|
|
263
|
+
return this.call<true>("setWebhook", params);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
deleteWebhook(dropPending = false): Promise<true> {
|
|
267
|
+
return this.call<true>("deleteWebhook", {
|
|
268
|
+
drop_pending_updates: dropPending,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getWebhookInfo(): Promise<{ url: string; pending_update_count: number }> {
|
|
273
|
+
return this.call<{ url: string; pending_update_count: number }>("getWebhookInfo", {});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Minimal subset of Telegram Bot API types we use.
|
|
2
|
+
|
|
3
|
+
export interface TgUser {
|
|
4
|
+
id: number;
|
|
5
|
+
is_bot?: boolean;
|
|
6
|
+
first_name?: string;
|
|
7
|
+
last_name?: string;
|
|
8
|
+
username?: string;
|
|
9
|
+
language_code?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TgChat {
|
|
13
|
+
id: number;
|
|
14
|
+
type: "private" | "group" | "supergroup" | "channel";
|
|
15
|
+
title?: string;
|
|
16
|
+
username?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One frame of a Telegram photo upload. Telegram delivers an array of
|
|
21
|
+
* sizes per photo (thumbnail / medium / original) — we record the
|
|
22
|
+
* largest for diagnostic purposes; counting is by message, not by size.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Result of `getFile`. `file_path` is a relative path the Bot File API
|
|
26
|
+
* appends to `https://api.telegram.org/file/bot<TOKEN>/...` to download
|
|
27
|
+
* the bytes. The path is short-lived (~1 hour) and tied to the token.
|
|
28
|
+
*/
|
|
29
|
+
export interface TgFile {
|
|
30
|
+
file_id: string;
|
|
31
|
+
file_unique_id: string;
|
|
32
|
+
file_size?: number;
|
|
33
|
+
/** Relative path. Undefined when the file is too large (Telegram caps
|
|
34
|
+
* download API at ~20 MB). */
|
|
35
|
+
file_path?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TgPhotoSize {
|
|
39
|
+
file_id: string;
|
|
40
|
+
file_unique_id: string;
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
file_size?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TgVideo {
|
|
47
|
+
file_id: string;
|
|
48
|
+
file_unique_id: string;
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
duration: number;
|
|
52
|
+
mime_type?: string;
|
|
53
|
+
file_size?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TgVoice {
|
|
57
|
+
file_id: string;
|
|
58
|
+
file_unique_id: string;
|
|
59
|
+
duration: number;
|
|
60
|
+
mime_type?: string;
|
|
61
|
+
file_size?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** «Кружок» — видео-сообщение Telegram (квадратное короткое видео). */
|
|
65
|
+
export interface TgVideoNote {
|
|
66
|
+
file_id: string;
|
|
67
|
+
file_unique_id: string;
|
|
68
|
+
length: number;
|
|
69
|
+
duration: number;
|
|
70
|
+
file_size?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TgDocument {
|
|
74
|
+
file_id: string;
|
|
75
|
+
file_unique_id: string;
|
|
76
|
+
file_name?: string;
|
|
77
|
+
mime_type?: string;
|
|
78
|
+
file_size?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TgMessage {
|
|
82
|
+
message_id: number;
|
|
83
|
+
from?: TgUser;
|
|
84
|
+
chat: TgChat;
|
|
85
|
+
date: number;
|
|
86
|
+
text?: string;
|
|
87
|
+
/** Caption that can accompany media uploads (photo / video / document /
|
|
88
|
+
* voice). Treated as the message's "text" by the persistence layer
|
|
89
|
+
* so RAG / extractors see the candidate's words. */
|
|
90
|
+
caption?: string;
|
|
91
|
+
/** Photo upload — present on photo messages, includes all delivered sizes. */
|
|
92
|
+
photo?: TgPhotoSize[];
|
|
93
|
+
video?: TgVideo;
|
|
94
|
+
voice?: TgVoice;
|
|
95
|
+
/** «Кружок» — видео-сообщение (video note). Для видео-верификации. */
|
|
96
|
+
video_note?: TgVideoNote;
|
|
97
|
+
document?: TgDocument;
|
|
98
|
+
/** When this message is a reply to another, Telegram inlines the
|
|
99
|
+
* parent message here. We use it to detect operator replies to lead
|
|
100
|
+
* cards (matched by the parent's `message_id` against
|
|
101
|
+
* `leads.ops_message_id`). */
|
|
102
|
+
reply_to_message?: TgMessage;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface TgCallbackQuery {
|
|
106
|
+
id: string;
|
|
107
|
+
from: TgUser;
|
|
108
|
+
data?: string;
|
|
109
|
+
message?: TgMessage;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface TgUpdate {
|
|
113
|
+
update_id: number;
|
|
114
|
+
message?: TgMessage;
|
|
115
|
+
edited_message?: TgMessage;
|
|
116
|
+
callback_query?: TgCallbackQuery;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface TgSendMessageResult {
|
|
120
|
+
message_id: number;
|
|
121
|
+
chat: TgChat;
|
|
122
|
+
date: number;
|
|
123
|
+
text?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface TgInlineKeyboardButton {
|
|
127
|
+
text: string;
|
|
128
|
+
url?: string;
|
|
129
|
+
callback_data?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface TgReplyMarkup {
|
|
133
|
+
inline_keyboard?: TgInlineKeyboardButton[][];
|
|
134
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { TgUpdate } from "./types.ts";
|
|
3
|
+
import { parseUpdate } from "./update-parser.ts";
|
|
4
|
+
|
|
5
|
+
const CH = "telegram-bot-1";
|
|
6
|
+
|
|
7
|
+
describe("parseUpdate", () => {
|
|
8
|
+
it("парсит текстовое сообщение в одну часть kind=text", () => {
|
|
9
|
+
const update: TgUpdate = {
|
|
10
|
+
update_id: 1,
|
|
11
|
+
message: {
|
|
12
|
+
message_id: 42,
|
|
13
|
+
chat: { id: 100, type: "private" },
|
|
14
|
+
from: { id: 100, first_name: "Alice", username: "alice" },
|
|
15
|
+
date: 1700000000,
|
|
16
|
+
text: "Привет",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const inbound = parseUpdate(CH, update);
|
|
20
|
+
expect(inbound).not.toBeNull();
|
|
21
|
+
expect(inbound).toMatchObject({
|
|
22
|
+
channelId: CH,
|
|
23
|
+
externalMessageId: "42",
|
|
24
|
+
externalUserId: "100",
|
|
25
|
+
externalUsername: "alice",
|
|
26
|
+
parts: [{ kind: "text", text: "Привет" }],
|
|
27
|
+
receivedAt: 1700000000,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("парсит фото с caption, выбирая наибольший size", () => {
|
|
32
|
+
const update: TgUpdate = {
|
|
33
|
+
update_id: 2,
|
|
34
|
+
message: {
|
|
35
|
+
message_id: 7,
|
|
36
|
+
chat: { id: 200, type: "private" },
|
|
37
|
+
from: { id: 200 },
|
|
38
|
+
date: 1700000000,
|
|
39
|
+
caption: "look",
|
|
40
|
+
photo: [
|
|
41
|
+
{ file_id: "small", file_unique_id: "u1", width: 90, height: 90 },
|
|
42
|
+
{ file_id: "BIG", file_unique_id: "u2", width: 800, height: 800 },
|
|
43
|
+
{ file_id: "mid", file_unique_id: "u3", width: 400, height: 400 },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const inbound = parseUpdate(CH, update);
|
|
48
|
+
expect(inbound?.parts).toEqual([
|
|
49
|
+
{
|
|
50
|
+
kind: "photo",
|
|
51
|
+
mediaRef: { channelId: CH, externalRef: "BIG" },
|
|
52
|
+
caption: "look",
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("парсит voice как InboundPart с durationSec", () => {
|
|
58
|
+
const update: TgUpdate = {
|
|
59
|
+
update_id: 3,
|
|
60
|
+
message: {
|
|
61
|
+
message_id: 8,
|
|
62
|
+
chat: { id: 300, type: "private" },
|
|
63
|
+
from: { id: 300 },
|
|
64
|
+
date: 1700000000,
|
|
65
|
+
voice: { file_id: "vox", file_unique_id: "vu", duration: 12, mime_type: "audio/ogg" },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const inbound = parseUpdate(CH, update);
|
|
69
|
+
expect(inbound?.parts).toEqual([
|
|
70
|
+
{
|
|
71
|
+
kind: "voice",
|
|
72
|
+
mediaRef: { channelId: CH, externalRef: "vox" },
|
|
73
|
+
durationSec: 12,
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("парсит document с mimeType и fileName", () => {
|
|
79
|
+
const update: TgUpdate = {
|
|
80
|
+
update_id: 4,
|
|
81
|
+
message: {
|
|
82
|
+
message_id: 9,
|
|
83
|
+
chat: { id: 400, type: "private" },
|
|
84
|
+
from: { id: 400 },
|
|
85
|
+
date: 1700000000,
|
|
86
|
+
document: {
|
|
87
|
+
file_id: "doc1",
|
|
88
|
+
file_unique_id: "du",
|
|
89
|
+
file_name: "passport.pdf",
|
|
90
|
+
mime_type: "application/pdf",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const inbound = parseUpdate(CH, update);
|
|
95
|
+
expect(inbound?.parts).toEqual([
|
|
96
|
+
{
|
|
97
|
+
kind: "document",
|
|
98
|
+
mediaRef: { channelId: CH, externalRef: "doc1" },
|
|
99
|
+
mimeType: "application/pdf",
|
|
100
|
+
fileName: "passport.pdf",
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("парсит callback_query как kind=callback_query", () => {
|
|
106
|
+
const update: TgUpdate = {
|
|
107
|
+
update_id: 5,
|
|
108
|
+
callback_query: {
|
|
109
|
+
id: "cb-123",
|
|
110
|
+
from: { id: 500, username: "operator" },
|
|
111
|
+
data: "approve:42",
|
|
112
|
+
message: {
|
|
113
|
+
message_id: 999,
|
|
114
|
+
chat: { id: 500, type: "private" },
|
|
115
|
+
date: 1700000000,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const inbound = parseUpdate(CH, update);
|
|
120
|
+
expect(inbound?.parts).toEqual([
|
|
121
|
+
{ kind: "callback_query", data: "approve:42", originalMessageId: "999" },
|
|
122
|
+
]);
|
|
123
|
+
expect(inbound?.externalUserId).toBe("500");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("возвращает null для пустого update без message/callback", () => {
|
|
127
|
+
expect(parseUpdate(CH, { update_id: 6 })).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("возвращает null для сообщения без 'from' (анонимные канал-посты)", () => {
|
|
131
|
+
const update: TgUpdate = {
|
|
132
|
+
update_id: 7,
|
|
133
|
+
message: {
|
|
134
|
+
message_id: 1,
|
|
135
|
+
chat: { id: 1, type: "channel" },
|
|
136
|
+
date: 1700000000,
|
|
137
|
+
text: "anon",
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
expect(parseUpdate(CH, update)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Inbound, InboundPart, MediaRef } from "@chatman-media/channel-core";
|
|
2
|
+
import type { TgMessage, TgPhotoSize, TgUpdate } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const ref = (channelId: string, externalRef: string): MediaRef => ({
|
|
5
|
+
channelId,
|
|
6
|
+
externalRef,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Берёт самую крупную доступную size фотографии. Telegram отдаёт массив
|
|
11
|
+
* sizes отсортированный по размеру; мы хотим original.
|
|
12
|
+
*/
|
|
13
|
+
function largestPhoto(photo: TgPhotoSize[]): TgPhotoSize | undefined {
|
|
14
|
+
if (photo.length === 0) return undefined;
|
|
15
|
+
return photo.reduce((biggest, current) =>
|
|
16
|
+
current.width * current.height > biggest.width * biggest.height ? current : biggest,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function partsFromMessage(channelId: string, msg: TgMessage): InboundPart[] {
|
|
21
|
+
const parts: InboundPart[] = [];
|
|
22
|
+
|
|
23
|
+
// Caption у photo/video/document обрабатывается ВНУТРИ соответствующего part'а,
|
|
24
|
+
// а у голого текстового message живёт в .text.
|
|
25
|
+
if (msg.text) {
|
|
26
|
+
parts.push({ kind: "text", text: msg.text });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (msg.photo && msg.photo.length > 0) {
|
|
30
|
+
const biggest = largestPhoto(msg.photo);
|
|
31
|
+
if (biggest) {
|
|
32
|
+
const caption = msg.caption ?? undefined;
|
|
33
|
+
parts.push({
|
|
34
|
+
kind: "photo",
|
|
35
|
+
mediaRef: ref(channelId, biggest.file_id),
|
|
36
|
+
...(caption ? { caption } : {}),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
} else if (msg.video) {
|
|
40
|
+
parts.push({
|
|
41
|
+
kind: "video",
|
|
42
|
+
mediaRef: ref(channelId, msg.video.file_id),
|
|
43
|
+
...(msg.caption ? { caption: msg.caption } : {}),
|
|
44
|
+
});
|
|
45
|
+
} else if (msg.voice) {
|
|
46
|
+
parts.push({
|
|
47
|
+
kind: "voice",
|
|
48
|
+
mediaRef: ref(channelId, msg.voice.file_id),
|
|
49
|
+
durationSec: msg.voice.duration,
|
|
50
|
+
});
|
|
51
|
+
} else if (msg.video_note) {
|
|
52
|
+
// «Кружок» — видео-сообщение Telegram. Используется для видео-верификации.
|
|
53
|
+
parts.push({
|
|
54
|
+
kind: "video_note",
|
|
55
|
+
mediaRef: ref(channelId, msg.video_note.file_id),
|
|
56
|
+
...(msg.video_note.duration ? { durationSec: msg.video_note.duration } : {}),
|
|
57
|
+
});
|
|
58
|
+
} else if (msg.document) {
|
|
59
|
+
parts.push({
|
|
60
|
+
kind: "document",
|
|
61
|
+
mediaRef: ref(channelId, msg.document.file_id),
|
|
62
|
+
...(msg.document.mime_type ? { mimeType: msg.document.mime_type } : {}),
|
|
63
|
+
...(msg.document.file_name ? { fileName: msg.document.file_name } : {}),
|
|
64
|
+
});
|
|
65
|
+
} else if (!msg.text && msg.caption) {
|
|
66
|
+
// Caption без media — теоретически невозможно по Bot API, но обработаем как text.
|
|
67
|
+
parts.push({ kind: "text", text: msg.caption });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return parts;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Маппит сырой Telegram Update в канал-агностичный Inbound (или null если
|
|
75
|
+
* update не относится к диалогу — например, чисто edited_message без изменений
|
|
76
|
+
* нашему пайплайну неинтересен).
|
|
77
|
+
*/
|
|
78
|
+
export function parseUpdate(channelId: string, update: TgUpdate): Inbound | null {
|
|
79
|
+
if (update.callback_query) {
|
|
80
|
+
const cb = update.callback_query;
|
|
81
|
+
const messageId = cb.message?.message_id;
|
|
82
|
+
if (cb.data === undefined || messageId === undefined) return null;
|
|
83
|
+
return {
|
|
84
|
+
channelId,
|
|
85
|
+
externalMessageId: String(cb.message?.message_id ?? cb.id),
|
|
86
|
+
externalUserId: String(cb.from.id),
|
|
87
|
+
...(cb.from.username ? { externalUsername: cb.from.username } : {}),
|
|
88
|
+
parts: [
|
|
89
|
+
{ kind: "callback_query", data: cb.data, originalMessageId: String(messageId) },
|
|
90
|
+
],
|
|
91
|
+
receivedAt: Math.floor(Date.now() / 1000),
|
|
92
|
+
raw: update,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const msg = update.message ?? update.edited_message;
|
|
97
|
+
if (!msg) return null;
|
|
98
|
+
if (!msg.from) return null;
|
|
99
|
+
|
|
100
|
+
const parts = partsFromMessage(channelId, msg);
|
|
101
|
+
if (parts.length === 0) return null;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
channelId,
|
|
105
|
+
externalMessageId: String(msg.message_id),
|
|
106
|
+
externalUserId: String(msg.from.id),
|
|
107
|
+
...(msg.from.username ? { externalUsername: msg.from.username } : {}),
|
|
108
|
+
parts,
|
|
109
|
+
receivedAt: msg.date,
|
|
110
|
+
raw: update,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export {
|
|
2
|
+
TelegramBotAdapter,
|
|
3
|
+
type TelegramBotAdapterOptions,
|
|
4
|
+
} from "./bot-api/adapter.ts";
|
|
5
|
+
export type { FetchLike, TelegramClientOptions } from "./bot-api/client.ts";
|
|
6
|
+
export { TelegramApiError, TelegramClient } from "./bot-api/client.ts";
|
|
7
|
+
export type {
|
|
8
|
+
TgCallbackQuery,
|
|
9
|
+
TgChat,
|
|
10
|
+
TgDocument,
|
|
11
|
+
TgFile,
|
|
12
|
+
TgInlineKeyboardButton,
|
|
13
|
+
TgMessage,
|
|
14
|
+
TgPhotoSize,
|
|
15
|
+
TgReplyMarkup,
|
|
16
|
+
TgSendMessageResult,
|
|
17
|
+
TgUpdate,
|
|
18
|
+
TgUser,
|
|
19
|
+
TgVideo,
|
|
20
|
+
TgVoice,
|
|
21
|
+
} from "./bot-api/types.ts";
|
|
22
|
+
export { parseUpdate } from "./bot-api/update-parser.ts";
|
|
23
|
+
export {
|
|
24
|
+
TelegramUserbotAdapter,
|
|
25
|
+
type TelegramUserbotAdapterOptions,
|
|
26
|
+
type UserbotHealthEvent,
|
|
27
|
+
type UserbotHealthStatus,
|
|
28
|
+
} from "./userbot/adapter.ts";
|
|
29
|
+
export {
|
|
30
|
+
type FinishedUserbotLogin,
|
|
31
|
+
finishUserbotLogin,
|
|
32
|
+
type StartedUserbotLogin,
|
|
33
|
+
startUserbotLogin,
|
|
34
|
+
submitUserbot2fa,
|
|
35
|
+
submitUserbotCode,
|
|
36
|
+
UserbotLoginError,
|
|
37
|
+
type UserbotLoginErrorCode,
|
|
38
|
+
} from "./userbot/login.ts";
|