@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,121 @@
|
|
|
1
|
+
import type { ChannelAdapter, ChannelCapabilities, DeleteOpts, EditOpts, Inbound, MediaRef, OutboundEnvelope, Sent } from "@chatman-media/channel-core";
|
|
2
|
+
/**
|
|
3
|
+
* Healthcheck-статусы MTProto userbot'а. apps/worker отслеживает их через
|
|
4
|
+
* healthEvents() async iterable и:
|
|
5
|
+
* - "connected" → нормальная работа
|
|
6
|
+
* - "auth_key_duplicated" → session revoked (оператор нажал "Terminate
|
|
7
|
+
* other sessions" в TG-клиенте, либо Telegram сам killед). Требует
|
|
8
|
+
* re-auth через admin-UI; supervisor НЕ должен respawn в петле.
|
|
9
|
+
* - "connection_failed" → transient (network blip); supervisor respawn'ит.
|
|
10
|
+
*/
|
|
11
|
+
export type UserbotHealthStatus = "connected" | "auth_key_duplicated" | "connection_failed";
|
|
12
|
+
export interface UserbotHealthEvent {
|
|
13
|
+
status: UserbotHealthStatus;
|
|
14
|
+
reason?: string;
|
|
15
|
+
at: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* MTProto userbot адаптер (личный аккаунт оператора через gramjs).
|
|
19
|
+
*
|
|
20
|
+
* Минимальная функциональность: connect, receive (NewMessage → Inbound),
|
|
21
|
+
* send (text), downloadMedia (через client.downloadMedia). Полная legacy-codebase
|
|
22
|
+
* импл'я имеет +supervisor для AUTH_KEY_DUPLICATED, delete-queue, per-
|
|
23
|
+
* conversation serialization, vision/photo-classify — это всё надстраивается
|
|
24
|
+
* сверху в conversation-engine и apps/worker.
|
|
25
|
+
*
|
|
26
|
+
* Жизненный цикл:
|
|
27
|
+
* 1. new TelegramUserbotAdapter({ id, apiId, apiHash, sessionString,
|
|
28
|
+
* onSessionUpdated })
|
|
29
|
+
* 2. await adapter.connect() — gramjs client.connect() с retry, при
|
|
30
|
+
* обновлённой session сохраняется callback'ом onSessionUpdated.
|
|
31
|
+
* 3. await for inbound of adapter.receive() — push'ит NewMessage events.
|
|
32
|
+
* 4. adapter.send(envelope) — text via gramjs.sendMessage(peer).
|
|
33
|
+
* 5. adapter.close() / signal.abort() — disconnect.
|
|
34
|
+
*/
|
|
35
|
+
export interface TelegramUserbotAdapterOptions {
|
|
36
|
+
id: string;
|
|
37
|
+
apiId: number;
|
|
38
|
+
apiHash: string;
|
|
39
|
+
/**
|
|
40
|
+
* StringSession строка. Пустая = первичная auth (требует операторского
|
|
41
|
+
* вмешательства через admin /userbot-login flow). При успешном connect
|
|
42
|
+
* gramjs может выдать обновлённый session — это сохраняется через
|
|
43
|
+
* onSessionUpdated callback.
|
|
44
|
+
*/
|
|
45
|
+
sessionString: string;
|
|
46
|
+
/**
|
|
47
|
+
* Callback при обновлении session-string'а после connect или re-auth.
|
|
48
|
+
* apps/worker записывает её в userbot_session таблицу.
|
|
49
|
+
*/
|
|
50
|
+
onSessionUpdated?: (sessionString: string) => Promise<void> | void;
|
|
51
|
+
/** Количество retry connect'ов, default 5. */
|
|
52
|
+
connectionRetries?: number;
|
|
53
|
+
/** ms между connect-попытками, default 5000. */
|
|
54
|
+
retryDelayMs?: number;
|
|
55
|
+
}
|
|
56
|
+
export declare class TelegramUserbotAdapter implements ChannelAdapter {
|
|
57
|
+
readonly kind: "telegram_userbot";
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly capabilities: ChannelCapabilities;
|
|
60
|
+
private readonly opts;
|
|
61
|
+
private client;
|
|
62
|
+
private readonly inbox;
|
|
63
|
+
private waiters;
|
|
64
|
+
private closed;
|
|
65
|
+
/**
|
|
66
|
+
* LRU-кэш Message-объектов для последующего downloadMedia. Ключ —
|
|
67
|
+
* `${external_user_id}:${msg_id}`. Размер ограничен MAX_RECENT_MESSAGES
|
|
68
|
+
* чтобы long-running процесс не утекал. Если worker'у нужен старый
|
|
69
|
+
* media — он должен скачать его сразу в onInbound, не отсрочивать.
|
|
70
|
+
*/
|
|
71
|
+
private static readonly MAX_RECENT_MESSAGES;
|
|
72
|
+
private readonly recentMessages;
|
|
73
|
+
/** Healthcheck events queue (separate от inbox'а). */
|
|
74
|
+
private readonly healthQueue;
|
|
75
|
+
private healthWaiters;
|
|
76
|
+
constructor(opts: TelegramUserbotAdapterOptions);
|
|
77
|
+
/**
|
|
78
|
+
* Установить MTProto-соединение. Бросает Error если все retry'и failед —
|
|
79
|
+
* supervisor в apps/worker делает respawn. Идемпотентно при повторных
|
|
80
|
+
* вызовах (no-op если уже connected).
|
|
81
|
+
*/
|
|
82
|
+
connect(): Promise<void>;
|
|
83
|
+
private emitHealth;
|
|
84
|
+
/**
|
|
85
|
+
* Async iterable healthcheck-events для supervisor'а в apps/worker.
|
|
86
|
+
* Emit'ится при connect success ("connected") / connect failure
|
|
87
|
+
* ("connection_failed") / auth revocation ("auth_key_duplicated").
|
|
88
|
+
* Supervisor читает их и решает: respawn vs notify-operator-and-stop.
|
|
89
|
+
*/
|
|
90
|
+
healthEvents(): AsyncIterable<UserbotHealthEvent>;
|
|
91
|
+
private registerHandler;
|
|
92
|
+
private cacheMessage;
|
|
93
|
+
private eventToInbound;
|
|
94
|
+
private hasMedia;
|
|
95
|
+
private mediaToPart;
|
|
96
|
+
private enqueue;
|
|
97
|
+
close(): Promise<void>;
|
|
98
|
+
receive(signal?: AbortSignal): AsyncIterable<Inbound>;
|
|
99
|
+
send(envelope: OutboundEnvelope): Promise<Sent>;
|
|
100
|
+
edit(_opts: EditOpts): Promise<void>;
|
|
101
|
+
delete(opts: DeleteOpts): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Скачивает медиа через cached Message-объект. mediaRef.externalRef —
|
|
104
|
+
* msg.id из last-received Inbound; caller вызывает downloadMedia сразу
|
|
105
|
+
* после inbound'а (worker pipe'ит в media-кэш).
|
|
106
|
+
*
|
|
107
|
+
* mediaRef.externalRef сам по себе не хватает (gramjs.downloadMedia
|
|
108
|
+
* требует Message), поэтому мы кэшируем последние MAX_RECENT_MESSAGES
|
|
109
|
+
* объектов в recentMessages Map при registerHandler.
|
|
110
|
+
*
|
|
111
|
+
* Throws если: client не подключён / mediaRef не найден в кэше /
|
|
112
|
+
* downloadMedia ошибается.
|
|
113
|
+
*/
|
|
114
|
+
downloadMedia(mediaRef: MediaRef, opts?: {
|
|
115
|
+
externalUserId?: string;
|
|
116
|
+
}): Promise<Response>;
|
|
117
|
+
signalTyping(externalUserId: string): Promise<void>;
|
|
118
|
+
/** Резолв peer'а по external_user_id (string из Inbound). */
|
|
119
|
+
private resolvePeer;
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/userbot/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,mBAAmB,EACnB,UAAU,EACV,QAAQ,EACR,OAAO,EAEP,QAAQ,EACR,gBAAgB,EAChB,IAAI,EACL,MAAM,6BAA6B,CAAC;AAMrC;;;;;;;;GAQG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,qBAAqB,GAAG,mBAAmB,CAAC;AAE5F,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,mBAAmB,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,6BAA6B;IAC5C,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACnE,8CAA8C;IAC9C,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gDAAgD;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAcD,qBAAa,sBAAuB,YAAW,cAAc;IAC3D,QAAQ,CAAC,IAAI,EAAG,kBAAkB,CAAU;IAC5C,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,YAAY,sBAA2B;IAEhD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAgC;IACrD,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,OAAO,CAAmD;IAClE,OAAO,CAAC,MAAM,CAAS;IACvB;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAO;IAClD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA8B;IAC7D,sDAAsD;IACtD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;IACxD,OAAO,CAAC,aAAa,CAA8D;gBAEvE,IAAI,EAAE,6BAA6B;IAK/C;;;;OAIG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAwD9B,OAAO,CAAC,UAAU;IAUlB;;;;;OAKG;IACH,YAAY,IAAI,aAAa,CAAC,kBAAkB,CAAC;IAqBjD,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,YAAY;IAepB,OAAO,CAAC,cAAc;IA+BtB,OAAO,CAAC,QAAQ;IAKhB,OAAO,CAAC,WAAW;IAgDnB,OAAO,CAAC,OAAO;IAST,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAqB5B,OAAO,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,OAAO,CAAC;IAoC/C,IAAI,CAAC,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsC/C,IAAI,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpC,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAgB7C;;;;;;;;;;;OAWG;IACG,aAAa,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,QAAQ,CAAC;IA2BxF,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBzD,6DAA6D;YAC/C,WAAW;CAQ1B"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { TelegramClient } from "telegram";
|
|
2
|
+
/**
|
|
3
|
+
* Пошаговый MTProto-логин для onboarding'а personal-account userbot'а.
|
|
4
|
+
*
|
|
5
|
+
* GramJS высокоуровневый `client.start()` интерактивен (callback'и для кода
|
|
6
|
+
* и пароля) — не годится для stateless HTTP. Здесь логин разбит на шаги:
|
|
7
|
+
* 1. startUserbotLogin(phone) → connect + sendCode → { client, phoneCodeHash }
|
|
8
|
+
* 2. submitUserbotCode({client, code}) → auth.SignIn; при 2FA → needs2fa:true
|
|
9
|
+
* 3. submitUserbot2fa({client, password}) → account.GetPassword + SRP CheckPassword
|
|
10
|
+
*
|
|
11
|
+
* Между шагами вызывающая сторона (apps/api) ДОЛЖНА держать тот же `client`
|
|
12
|
+
* в памяти — phoneCodeHash и auth-key привязаны к этому live-соединению.
|
|
13
|
+
* По завершении session-string сохраняется (encrypted) и адаптер reconnect'ится.
|
|
14
|
+
*/
|
|
15
|
+
export type UserbotLoginErrorCode = "phone_invalid" | "code_invalid" | "code_expired" | "password_invalid" | "flood_wait" | "unknown";
|
|
16
|
+
export declare class UserbotLoginError extends Error {
|
|
17
|
+
readonly code: UserbotLoginErrorCode;
|
|
18
|
+
/** Для flood_wait — сколько секунд ждать. */
|
|
19
|
+
readonly retryAfterSec?: number | undefined;
|
|
20
|
+
constructor(code: UserbotLoginErrorCode, message: string,
|
|
21
|
+
/** Для flood_wait — сколько секунд ждать. */
|
|
22
|
+
retryAfterSec?: number | undefined);
|
|
23
|
+
}
|
|
24
|
+
export interface StartedUserbotLogin {
|
|
25
|
+
client: TelegramClient;
|
|
26
|
+
phoneCodeHash: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FinishedUserbotLogin {
|
|
29
|
+
sessionString: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
username: string | null;
|
|
32
|
+
phone: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** Шаг 1: подключиться и отправить код подтверждения на номер. */
|
|
35
|
+
export declare function startUserbotLogin(opts: {
|
|
36
|
+
apiId: number;
|
|
37
|
+
apiHash: string;
|
|
38
|
+
phone: string;
|
|
39
|
+
}): Promise<StartedUserbotLogin>;
|
|
40
|
+
/**
|
|
41
|
+
* Шаг 2: отправить код. Если у аккаунта включён 2FA — Telegram вернёт
|
|
42
|
+
* SESSION_PASSWORD_NEEDED, возвращаем { needs2fa: true } (логин не завершён).
|
|
43
|
+
* Иначе логин завершён — возвращаем session-string.
|
|
44
|
+
*/
|
|
45
|
+
export declare function submitUserbotCode(opts: {
|
|
46
|
+
client: TelegramClient;
|
|
47
|
+
phone: string;
|
|
48
|
+
phoneCodeHash: string;
|
|
49
|
+
code: string;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
needs2fa: true;
|
|
52
|
+
} | ({
|
|
53
|
+
needs2fa: false;
|
|
54
|
+
} & FinishedUserbotLogin)>;
|
|
55
|
+
/** Шаг 3 (опционально): отправить пароль 2FA через SRP. */
|
|
56
|
+
export declare function submitUserbot2fa(opts: {
|
|
57
|
+
client: TelegramClient;
|
|
58
|
+
password: string;
|
|
59
|
+
}): Promise<FinishedUserbotLogin>;
|
|
60
|
+
/** Сохранить session-string + получить идентификаторы аккаунта. */
|
|
61
|
+
export declare function finishUserbotLogin(client: TelegramClient): Promise<FinishedUserbotLogin>;
|
|
62
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/userbot/login.ts"],"names":[],"mappings":"AAAA,OAAO,EAAO,cAAc,EAAE,MAAM,UAAU,CAAC;AAI/C;;;;;;;;;;;;GAYG;AAEH,MAAM,MAAM,qBAAqB,GAC7B,eAAe,GACf,cAAc,GACd,cAAc,GACd,kBAAkB,GAClB,YAAY,GACZ,SAAS,CAAC;AAEd,qBAAa,iBAAkB,SAAQ,KAAK;IAExC,QAAQ,CAAC,IAAI,EAAE,qBAAqB;IAEpC,6CAA6C;IAC7C,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM;gBAHtB,IAAI,EAAE,qBAAqB,EACpC,OAAO,EAAE,MAAM;IACf,6CAA6C;IACpC,aAAa,CAAC,EAAE,MAAM,YAAA;CAKlC;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,cAAc,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAgCD,kEAAkE;AAClE,wBAAsB,iBAAiB,CAAC,IAAI,EAAE;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAe/B;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE;IAC5C,MAAM,EAAE,cAAc,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,IAAI,CAAA;CAAE,GAAG,CAAC;IAAE,QAAQ,EAAE,KAAK,CAAA;CAAE,GAAG,oBAAoB,CAAC,CAAC,CAoB7E;AAED,2DAA2D;AAC3D,wBAAsB,gBAAgB,CAAC,IAAI,EAAE;IAC3C,MAAM,EAAE,cAAc,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAShC;AAED,mEAAmE;AACnE,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAS9F"}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chatman-media/channel-telegram",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Telegram-каналы (BotAPI + MTProto userbot) как реализация ChannelAdapter из @chatman-media/channel-core.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"bun": "./src/index.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external @chatman-media/channel-core && tsc -p tsconfig.build.json",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"check": "biome check ./src",
|
|
26
|
+
"format": "biome format --write ./src",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"prepublishOnly": "bun run build"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"lead-engine",
|
|
32
|
+
"channel",
|
|
33
|
+
"telegram",
|
|
34
|
+
"mtproto",
|
|
35
|
+
"bot-api"
|
|
36
|
+
],
|
|
37
|
+
"author": "Alexander Kireev",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@chatman-media/channel-core": "1.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"telegram": ">=2.26.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"telegram": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"optionalDependencies": {
|
|
51
|
+
"telegram": "^2.26.22"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@biomejs/biome": "^2.4.14",
|
|
55
|
+
"@types/bun": "1.3.14",
|
|
56
|
+
"typescript": "^6.0.3"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/chatman-media/lead-engine.git",
|
|
61
|
+
"directory": "packages/channel-telegram"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://github.com/chatman-media/lead-engine/tree/main/packages/channel-telegram#readme",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/chatman-media/lead-engine/issues"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { TelegramBotAdapter } from "./adapter.ts";
|
|
3
|
+
import type { TgUpdate } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
interface RecordedCall {
|
|
6
|
+
url: string;
|
|
7
|
+
body: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function fakeFetch(): { fetch: typeof fetch; calls: RecordedCall[] } {
|
|
11
|
+
const calls: RecordedCall[] = [];
|
|
12
|
+
const fn = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
13
|
+
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
|
14
|
+
calls.push({ url: String(url), body });
|
|
15
|
+
return new Response(JSON.stringify({ ok: true, result: { message_id: 1234 } }), {
|
|
16
|
+
status: 200,
|
|
17
|
+
headers: { "content-type": "application/json" },
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
return { fetch: fn as unknown as typeof fetch, calls };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("TelegramBotAdapter", () => {
|
|
24
|
+
it("маппит OutboundEnvelope c text-part в sendMessage с chat_id/text", async () => {
|
|
25
|
+
const { fetch, calls } = fakeFetch();
|
|
26
|
+
const adapter = new TelegramBotAdapter({ id: "tg1", token: "TKN", fetch });
|
|
27
|
+
const sent = await adapter.send({
|
|
28
|
+
channelId: "tg1",
|
|
29
|
+
externalUserId: "12345",
|
|
30
|
+
parts: [{ kind: "text", text: "hello" }],
|
|
31
|
+
});
|
|
32
|
+
expect(sent.externalMessageId).toBe("1234");
|
|
33
|
+
expect(calls).toHaveLength(1);
|
|
34
|
+
const [call] = calls;
|
|
35
|
+
expect(call?.url).toBe("https://api.telegram.org/botTKN/sendMessage");
|
|
36
|
+
expect(call?.body).toEqual({ chat_id: 12345, text: "hello" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("маппит inline-кнопки на reply_markup, и кладёт их только на последнее сообщение", async () => {
|
|
40
|
+
const { fetch, calls } = fakeFetch();
|
|
41
|
+
const adapter = new TelegramBotAdapter({ id: "tg1", token: "TKN", fetch });
|
|
42
|
+
await adapter.send({
|
|
43
|
+
channelId: "tg1",
|
|
44
|
+
externalUserId: "10",
|
|
45
|
+
parts: [
|
|
46
|
+
{ kind: "text", text: "one" },
|
|
47
|
+
{ kind: "text", text: "two" },
|
|
48
|
+
],
|
|
49
|
+
replyMarkup: { inlineButtons: [[{ label: "Yes", callbackData: "y" }]] },
|
|
50
|
+
});
|
|
51
|
+
expect(calls).toHaveLength(2);
|
|
52
|
+
expect(calls[0]?.body).not.toHaveProperty("reply_markup");
|
|
53
|
+
expect(calls[1]?.body).toMatchObject({
|
|
54
|
+
reply_markup: { inline_keyboard: [[{ text: "Yes", callback_data: "y" }]] },
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("receive() возвращает Inbound из pushUpdate без race conditions", async () => {
|
|
59
|
+
const { fetch } = fakeFetch();
|
|
60
|
+
const adapter = new TelegramBotAdapter({ id: "tg1", token: "TKN", fetch });
|
|
61
|
+
const update: TgUpdate = {
|
|
62
|
+
update_id: 1,
|
|
63
|
+
message: {
|
|
64
|
+
message_id: 9,
|
|
65
|
+
chat: { id: 7, type: "private" },
|
|
66
|
+
from: { id: 7, username: "kate" },
|
|
67
|
+
date: 1700000000,
|
|
68
|
+
text: "ping",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
// Push до начала чтения — Inbound должен лежать в inbox.
|
|
72
|
+
adapter.pushUpdate(update);
|
|
73
|
+
const iter = adapter.receive()[Symbol.asyncIterator]();
|
|
74
|
+
const next = await iter.next();
|
|
75
|
+
expect(next.done).toBe(false);
|
|
76
|
+
expect(next.value.parts).toEqual([{ kind: "text", text: "ping" }]);
|
|
77
|
+
|
|
78
|
+
// Закрываем стрим — receive завершается.
|
|
79
|
+
adapter.close();
|
|
80
|
+
const last = await iter.next();
|
|
81
|
+
expect(last.done).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("receive() ждёт pushUpdate если очередь пуста (resolver-pattern)", async () => {
|
|
85
|
+
const { fetch } = fakeFetch();
|
|
86
|
+
const adapter = new TelegramBotAdapter({ id: "tg1", token: "TKN", fetch });
|
|
87
|
+
const iter = adapter.receive()[Symbol.asyncIterator]();
|
|
88
|
+
const nextPromise = iter.next();
|
|
89
|
+
// Через микротик пушим update — Promise должен разрешиться.
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
adapter.pushUpdate({
|
|
92
|
+
update_id: 2,
|
|
93
|
+
message: {
|
|
94
|
+
message_id: 1,
|
|
95
|
+
chat: { id: 1, type: "private" },
|
|
96
|
+
from: { id: 1 },
|
|
97
|
+
date: 0,
|
|
98
|
+
text: "async",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
const next = await nextPromise;
|
|
103
|
+
expect(next.value.parts).toEqual([{ kind: "text", text: "async" }]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAdapter,
|
|
3
|
+
ChannelCapabilities,
|
|
4
|
+
DeleteOpts,
|
|
5
|
+
EditOpts,
|
|
6
|
+
Inbound,
|
|
7
|
+
MediaRef,
|
|
8
|
+
OutboundEnvelope,
|
|
9
|
+
OutboundPart,
|
|
10
|
+
ReplyMarkup,
|
|
11
|
+
Sent,
|
|
12
|
+
} from "@chatman-media/channel-core";
|
|
13
|
+
import { TelegramClient, type TelegramClientOptions } from "./client.ts";
|
|
14
|
+
import type { TgReplyMarkup, TgUpdate } from "./types.ts";
|
|
15
|
+
import { parseUpdate } from "./update-parser.ts";
|
|
16
|
+
|
|
17
|
+
export interface TelegramBotAdapterOptions extends TelegramClientOptions {
|
|
18
|
+
/** Уникальный id канала в платформе (соответствует `channels.id` в БД). */
|
|
19
|
+
id: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TG_BOT_CAPABILITIES: ChannelCapabilities = {
|
|
23
|
+
text: true,
|
|
24
|
+
photo: true,
|
|
25
|
+
video: true,
|
|
26
|
+
// Bot API позволяет отправлять voice через файл, но в нашем пайплайне голос
|
|
27
|
+
// только входит (от кандидата), не отправляется ботом. Если понадобится —
|
|
28
|
+
// расширим OutboundPart и снимем флаг.
|
|
29
|
+
voice: false,
|
|
30
|
+
document: true,
|
|
31
|
+
edit: true,
|
|
32
|
+
delete: true,
|
|
33
|
+
callbackQuery: true,
|
|
34
|
+
typing: true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function toTgReplyMarkup(rm: ReplyMarkup | undefined): TgReplyMarkup | undefined {
|
|
38
|
+
if (!rm?.inlineButtons) return undefined;
|
|
39
|
+
return {
|
|
40
|
+
inline_keyboard: rm.inlineButtons.map((row) =>
|
|
41
|
+
row.map((b) => ({ text: b.label, callback_data: b.callbackData })),
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseMode(mode: "markdown" | "html" | undefined): "MarkdownV2" | "HTML" | undefined {
|
|
47
|
+
if (mode === "markdown") return "MarkdownV2";
|
|
48
|
+
if (mode === "html") return "HTML";
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Telegram Bot API адаптер. Получает входящие через `pushUpdate()` (HTTP webhook
|
|
54
|
+
* в apps/api дёргает этот метод на каждый incoming POST), отправляет исходящие
|
|
55
|
+
* через TelegramClient.
|
|
56
|
+
*
|
|
57
|
+
* Адаптер не делает rate-limiting и retry сам — это ответственность outbound-
|
|
58
|
+
* dispatcher'а в apps/worker (использует `attempts`/`sent_at` в outbound_queue).
|
|
59
|
+
*/
|
|
60
|
+
export class TelegramBotAdapter implements ChannelAdapter {
|
|
61
|
+
readonly kind = "telegram_bot" as const;
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly capabilities = TG_BOT_CAPABILITIES;
|
|
64
|
+
|
|
65
|
+
private readonly client: TelegramClient;
|
|
66
|
+
private readonly inbox: Inbound[] = [];
|
|
67
|
+
private waiters: Array<(v: IteratorResult<Inbound>) => void> = [];
|
|
68
|
+
private closed = false;
|
|
69
|
+
|
|
70
|
+
constructor(opts: TelegramBotAdapterOptions) {
|
|
71
|
+
this.id = opts.id;
|
|
72
|
+
this.client = new TelegramClient(opts);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Доступ к raw-клиенту для admin-операций (setWebhook, getMe, и т.д.). */
|
|
76
|
+
get rawClient(): TelegramClient {
|
|
77
|
+
return this.client;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Пушит сырой Telegram update в очередь. Парсится в Inbound; если update
|
|
82
|
+
* нерелевантен (нет сообщения и нет callback_query) — silent drop.
|
|
83
|
+
* Вызывается из HTTP-хэндлера webhook'а.
|
|
84
|
+
*/
|
|
85
|
+
pushUpdate(update: TgUpdate): void {
|
|
86
|
+
const inbound = parseUpdate(this.id, update);
|
|
87
|
+
if (!inbound) return;
|
|
88
|
+
this.enqueue(inbound);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private enqueue(inbound: Inbound): void {
|
|
92
|
+
const waiter = this.waiters.shift();
|
|
93
|
+
if (waiter) {
|
|
94
|
+
waiter({ value: inbound, done: false });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.inbox.push(inbound);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Закрыть стрим: receive() завершится после дренажа очереди. */
|
|
101
|
+
close(): void {
|
|
102
|
+
this.closed = true;
|
|
103
|
+
while (this.waiters.length > 0) {
|
|
104
|
+
const w = this.waiters.shift();
|
|
105
|
+
w?.({ value: undefined as unknown as Inbound, done: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
receive(signal?: AbortSignal): AsyncIterable<Inbound> {
|
|
110
|
+
const self = this;
|
|
111
|
+
return {
|
|
112
|
+
[Symbol.asyncIterator](): AsyncIterator<Inbound> {
|
|
113
|
+
return {
|
|
114
|
+
next(): Promise<IteratorResult<Inbound>> {
|
|
115
|
+
if (signal?.aborted) {
|
|
116
|
+
return Promise.resolve({ value: undefined as unknown as Inbound, done: true });
|
|
117
|
+
}
|
|
118
|
+
const queued = self.inbox.shift();
|
|
119
|
+
if (queued) {
|
|
120
|
+
return Promise.resolve({ value: queued, done: false });
|
|
121
|
+
}
|
|
122
|
+
if (self.closed) {
|
|
123
|
+
return Promise.resolve({ value: undefined as unknown as Inbound, done: true });
|
|
124
|
+
}
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
self.waiters.push(resolve);
|
|
127
|
+
if (signal) {
|
|
128
|
+
signal.addEventListener(
|
|
129
|
+
"abort",
|
|
130
|
+
() => {
|
|
131
|
+
const idx = self.waiters.indexOf(resolve);
|
|
132
|
+
if (idx >= 0) self.waiters.splice(idx, 1);
|
|
133
|
+
resolve({ value: undefined as unknown as Inbound, done: true });
|
|
134
|
+
},
|
|
135
|
+
{ once: true },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async send(envelope: OutboundEnvelope): Promise<Sent> {
|
|
146
|
+
if (envelope.parts.length === 0) {
|
|
147
|
+
throw new Error("OutboundEnvelope.parts must be non-empty");
|
|
148
|
+
}
|
|
149
|
+
const chatId = Number(envelope.externalUserId);
|
|
150
|
+
if (Number.isNaN(chatId)) {
|
|
151
|
+
throw new Error(`telegram-bot: invalid externalUserId "${envelope.externalUserId}"`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sequential send for каждой части — Bot API не поддерживает atomic batch.
|
|
155
|
+
// Возвращаем id ПЕРВОГО отправленного — это якорь для replyTo/edit/delete.
|
|
156
|
+
let firstMessageId: number | undefined;
|
|
157
|
+
const lastIdx = envelope.parts.length - 1;
|
|
158
|
+
for (let i = 0; i < envelope.parts.length; i++) {
|
|
159
|
+
const part = envelope.parts[i] as OutboundPart;
|
|
160
|
+
// reply_markup и reply_to_message_id применяются только к последней части,
|
|
161
|
+
// чтобы кнопки висели под финальным сообщением.
|
|
162
|
+
const isLast = i === lastIdx;
|
|
163
|
+
const replyMarkup = isLast ? toTgReplyMarkup(envelope.replyMarkup) : undefined;
|
|
164
|
+
const replyToMessageId =
|
|
165
|
+
i === 0 && envelope.replyToExternalMessageId !== undefined
|
|
166
|
+
? Number(envelope.replyToExternalMessageId)
|
|
167
|
+
: undefined;
|
|
168
|
+
|
|
169
|
+
let res: { message_id: number };
|
|
170
|
+
if (part.kind === "text") {
|
|
171
|
+
res = await this.client.sendMessage({
|
|
172
|
+
chatId,
|
|
173
|
+
text: part.text,
|
|
174
|
+
...(parseMode(part.parseMode) ? { parseMode: parseMode(part.parseMode) } : {}),
|
|
175
|
+
...(replyMarkup ? { replyMarkup } : {}),
|
|
176
|
+
...(replyToMessageId !== undefined ? { replyToMessageId } : {}),
|
|
177
|
+
});
|
|
178
|
+
} else if (part.kind === "photo") {
|
|
179
|
+
res = await this.client.sendPhoto({
|
|
180
|
+
chatId,
|
|
181
|
+
photoFileId: part.mediaRef.externalRef,
|
|
182
|
+
...(part.caption ? { caption: part.caption } : {}),
|
|
183
|
+
});
|
|
184
|
+
} else if (part.kind === "video") {
|
|
185
|
+
res = await this.client.sendVideo({
|
|
186
|
+
chatId,
|
|
187
|
+
videoFileId: part.mediaRef.externalRef,
|
|
188
|
+
...(part.caption ? { caption: part.caption } : {}),
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
res = await this.client.sendDocument({
|
|
192
|
+
chatId,
|
|
193
|
+
documentFileId: part.mediaRef.externalRef,
|
|
194
|
+
...(part.caption ? { caption: part.caption } : {}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (firstMessageId === undefined) firstMessageId = res.message_id;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
channelId: this.id,
|
|
202
|
+
externalMessageId: String(firstMessageId ?? ""),
|
|
203
|
+
sentAt: Math.floor(Date.now() / 1000),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async edit(opts: EditOpts): Promise<void> {
|
|
208
|
+
const chatId = Number(opts.externalUserId);
|
|
209
|
+
const messageId = Number(opts.externalMessageId);
|
|
210
|
+
if (Number.isNaN(chatId) || Number.isNaN(messageId)) {
|
|
211
|
+
throw new Error("telegram-bot: edit requires numeric externalUserId / externalMessageId");
|
|
212
|
+
}
|
|
213
|
+
await this.client.editMessageText({
|
|
214
|
+
chatId,
|
|
215
|
+
messageId,
|
|
216
|
+
text: opts.text,
|
|
217
|
+
...(parseMode(opts.parseMode) ? { parseMode: parseMode(opts.parseMode) } : {}),
|
|
218
|
+
...(toTgReplyMarkup(opts.replyMarkup)
|
|
219
|
+
? { replyMarkup: toTgReplyMarkup(opts.replyMarkup) as TgReplyMarkup }
|
|
220
|
+
: {}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async delete(opts: DeleteOpts): Promise<void> {
|
|
225
|
+
const chatId = Number(opts.externalUserId);
|
|
226
|
+
const messageId = Number(opts.externalMessageId);
|
|
227
|
+
if (Number.isNaN(chatId) || Number.isNaN(messageId)) {
|
|
228
|
+
throw new Error("telegram-bot: delete requires numeric externalUserId / externalMessageId");
|
|
229
|
+
}
|
|
230
|
+
await this.client.deleteMessage({ chatId, messageId });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
downloadMedia(mediaRef: MediaRef): Promise<Response> {
|
|
234
|
+
return this.client.downloadFile(mediaRef.externalRef);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async signalTyping(externalUserId: string): Promise<void> {
|
|
238
|
+
const chatId = Number(externalUserId);
|
|
239
|
+
if (Number.isNaN(chatId)) return;
|
|
240
|
+
await this.client.sendChatAction({ chatId, action: "typing" });
|
|
241
|
+
}
|
|
242
|
+
}
|