@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,517 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAdapter,
|
|
3
|
+
ChannelCapabilities,
|
|
4
|
+
DeleteOpts,
|
|
5
|
+
EditOpts,
|
|
6
|
+
Inbound,
|
|
7
|
+
InboundPart,
|
|
8
|
+
MediaRef,
|
|
9
|
+
OutboundEnvelope,
|
|
10
|
+
Sent,
|
|
11
|
+
} from "@chatman-media/channel-core";
|
|
12
|
+
import { Api, TelegramClient as GramjsClient } from "telegram";
|
|
13
|
+
import { NewMessage, type NewMessageEvent } from "telegram/events/index.js";
|
|
14
|
+
import { LogLevel } from "telegram/extensions/Logger.js";
|
|
15
|
+
import { StringSession } from "telegram/sessions/index.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Healthcheck-статусы MTProto userbot'а. apps/worker отслеживает их через
|
|
19
|
+
* healthEvents() async iterable и:
|
|
20
|
+
* - "connected" → нормальная работа
|
|
21
|
+
* - "auth_key_duplicated" → session revoked (оператор нажал "Terminate
|
|
22
|
+
* other sessions" в TG-клиенте, либо Telegram сам killед). Требует
|
|
23
|
+
* re-auth через admin-UI; supervisor НЕ должен respawn в петле.
|
|
24
|
+
* - "connection_failed" → transient (network blip); supervisor respawn'ит.
|
|
25
|
+
*/
|
|
26
|
+
export type UserbotHealthStatus = "connected" | "auth_key_duplicated" | "connection_failed";
|
|
27
|
+
|
|
28
|
+
export interface UserbotHealthEvent {
|
|
29
|
+
status: UserbotHealthStatus;
|
|
30
|
+
reason?: string;
|
|
31
|
+
at: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* MTProto userbot адаптер (личный аккаунт оператора через gramjs).
|
|
36
|
+
*
|
|
37
|
+
* Минимальная функциональность: connect, receive (NewMessage → Inbound),
|
|
38
|
+
* send (text), downloadMedia (через client.downloadMedia). Полная legacy-codebase
|
|
39
|
+
* импл'я имеет +supervisor для AUTH_KEY_DUPLICATED, delete-queue, per-
|
|
40
|
+
* conversation serialization, vision/photo-classify — это всё надстраивается
|
|
41
|
+
* сверху в conversation-engine и apps/worker.
|
|
42
|
+
*
|
|
43
|
+
* Жизненный цикл:
|
|
44
|
+
* 1. new TelegramUserbotAdapter({ id, apiId, apiHash, sessionString,
|
|
45
|
+
* onSessionUpdated })
|
|
46
|
+
* 2. await adapter.connect() — gramjs client.connect() с retry, при
|
|
47
|
+
* обновлённой session сохраняется callback'ом onSessionUpdated.
|
|
48
|
+
* 3. await for inbound of adapter.receive() — push'ит NewMessage events.
|
|
49
|
+
* 4. adapter.send(envelope) — text via gramjs.sendMessage(peer).
|
|
50
|
+
* 5. adapter.close() / signal.abort() — disconnect.
|
|
51
|
+
*/
|
|
52
|
+
export interface TelegramUserbotAdapterOptions {
|
|
53
|
+
id: string;
|
|
54
|
+
apiId: number;
|
|
55
|
+
apiHash: string;
|
|
56
|
+
/**
|
|
57
|
+
* StringSession строка. Пустая = первичная auth (требует операторского
|
|
58
|
+
* вмешательства через admin /userbot-login flow). При успешном connect
|
|
59
|
+
* gramjs может выдать обновлённый session — это сохраняется через
|
|
60
|
+
* onSessionUpdated callback.
|
|
61
|
+
*/
|
|
62
|
+
sessionString: string;
|
|
63
|
+
/**
|
|
64
|
+
* Callback при обновлении session-string'а после connect или re-auth.
|
|
65
|
+
* apps/worker записывает её в userbot_session таблицу.
|
|
66
|
+
*/
|
|
67
|
+
onSessionUpdated?: (sessionString: string) => Promise<void> | void;
|
|
68
|
+
/** Количество retry connect'ов, default 5. */
|
|
69
|
+
connectionRetries?: number;
|
|
70
|
+
/** ms между connect-попытками, default 5000. */
|
|
71
|
+
retryDelayMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TG_USERBOT_CAPABILITIES: ChannelCapabilities = {
|
|
75
|
+
text: true,
|
|
76
|
+
photo: true,
|
|
77
|
+
video: true,
|
|
78
|
+
voice: true,
|
|
79
|
+
document: true,
|
|
80
|
+
edit: false,
|
|
81
|
+
delete: true,
|
|
82
|
+
callbackQuery: false,
|
|
83
|
+
typing: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export class TelegramUserbotAdapter implements ChannelAdapter {
|
|
87
|
+
readonly kind = "telegram_userbot" as const;
|
|
88
|
+
readonly id: string;
|
|
89
|
+
readonly capabilities = TG_USERBOT_CAPABILITIES;
|
|
90
|
+
|
|
91
|
+
private readonly opts: TelegramUserbotAdapterOptions;
|
|
92
|
+
private client: GramjsClient | null = null;
|
|
93
|
+
private readonly inbox: Inbound[] = [];
|
|
94
|
+
private waiters: Array<(v: IteratorResult<Inbound>) => void> = [];
|
|
95
|
+
private closed = false;
|
|
96
|
+
/**
|
|
97
|
+
* LRU-кэш Message-объектов для последующего downloadMedia. Ключ —
|
|
98
|
+
* `${external_user_id}:${msg_id}`. Размер ограничен MAX_RECENT_MESSAGES
|
|
99
|
+
* чтобы long-running процесс не утекал. Если worker'у нужен старый
|
|
100
|
+
* media — он должен скачать его сразу в onInbound, не отсрочивать.
|
|
101
|
+
*/
|
|
102
|
+
private static readonly MAX_RECENT_MESSAGES = 256;
|
|
103
|
+
private readonly recentMessages = new Map<string, unknown>();
|
|
104
|
+
/** Healthcheck events queue (separate от inbox'а). */
|
|
105
|
+
private readonly healthQueue: UserbotHealthEvent[] = [];
|
|
106
|
+
private healthWaiters: Array<(v: IteratorResult<UserbotHealthEvent>) => void> = [];
|
|
107
|
+
|
|
108
|
+
constructor(opts: TelegramUserbotAdapterOptions) {
|
|
109
|
+
this.id = opts.id;
|
|
110
|
+
this.opts = opts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Установить MTProto-соединение. Бросает Error если все retry'и failед —
|
|
115
|
+
* supervisor в apps/worker делает respawn. Идемпотентно при повторных
|
|
116
|
+
* вызовах (no-op если уже connected).
|
|
117
|
+
*/
|
|
118
|
+
async connect(): Promise<void> {
|
|
119
|
+
if (this.client && this.client.connected) return;
|
|
120
|
+
const session = new StringSession(this.opts.sessionString);
|
|
121
|
+
const client = new GramjsClient(session, this.opts.apiId, this.opts.apiHash, {
|
|
122
|
+
// count, not flag — Infinity = бесконечно, 5 = разумный лимит чтобы
|
|
123
|
+
// supervisor мог перерестартить вместо infinite-spin'а.
|
|
124
|
+
connectionRetries: this.opts.connectionRetries ?? 5,
|
|
125
|
+
retryDelay: this.opts.retryDelayMs ?? 3000,
|
|
126
|
+
timeout: 30,
|
|
127
|
+
});
|
|
128
|
+
// GramJS на уровне ERROR console.error'ит рутинные ping-timeout'ы своего
|
|
129
|
+
// update-loop'а (он сам их ловит и делает reconnect — см. updates.js).
|
|
130
|
+
// Для long-running SaaS это шум; значимые статусы (connected /
|
|
131
|
+
// connection_failed / auth_key_duplicated) мы отдаём через healthEvents().
|
|
132
|
+
client.setLogLevel(LogLevel.NONE);
|
|
133
|
+
|
|
134
|
+
const maxAttempts = this.opts.connectionRetries ?? 5;
|
|
135
|
+
let lastErr: string | null = null;
|
|
136
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
137
|
+
try {
|
|
138
|
+
const ok = await client.connect();
|
|
139
|
+
if (ok) {
|
|
140
|
+
this.client = client;
|
|
141
|
+
// Persist updated session if changed.
|
|
142
|
+
const updated = client.session.save() as unknown as string;
|
|
143
|
+
if (updated !== this.opts.sessionString && this.opts.onSessionUpdated) {
|
|
144
|
+
await this.opts.onSessionUpdated(updated);
|
|
145
|
+
}
|
|
146
|
+
this.registerHandler(client);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
151
|
+
// AUTH_KEY_DUPLICATED / AUTH_KEY_UNREGISTERED — terminal состояние.
|
|
152
|
+
// Прерываем retry-loop, эмитим health event "auth_key_duplicated"
|
|
153
|
+
// и бросаем — supervisor НЕ должен respawn (требуется операторская
|
|
154
|
+
// re-auth через admin-UI).
|
|
155
|
+
if (lastErr && /AUTH_KEY_(DUPLICATED|UNREGISTERED)/i.test(lastErr)) {
|
|
156
|
+
this.emitHealth({ status: "auth_key_duplicated", reason: lastErr });
|
|
157
|
+
throw new Error(`[telegram-userbot] auth key revoked: ${lastErr}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (attempt < maxAttempts) {
|
|
161
|
+
await new Promise((r) => setTimeout(r, this.opts.retryDelayMs ?? 5000));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this.emitHealth({ status: "connection_failed", reason: lastErr ?? "no specific error" });
|
|
165
|
+
throw new Error(
|
|
166
|
+
`[telegram-userbot] connect failed after ${maxAttempts} attempts${
|
|
167
|
+
lastErr ? `: ${lastErr}` : ""
|
|
168
|
+
}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- Health events stream ---------------------------------------------
|
|
173
|
+
|
|
174
|
+
private emitHealth(event: Omit<UserbotHealthEvent, "at">): void {
|
|
175
|
+
const full: UserbotHealthEvent = { ...event, at: Math.floor(Date.now() / 1000) };
|
|
176
|
+
const waiter = this.healthWaiters.shift();
|
|
177
|
+
if (waiter) {
|
|
178
|
+
waiter({ value: full, done: false });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.healthQueue.push(full);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Async iterable healthcheck-events для supervisor'а в apps/worker.
|
|
186
|
+
* Emit'ится при connect success ("connected") / connect failure
|
|
187
|
+
* ("connection_failed") / auth revocation ("auth_key_duplicated").
|
|
188
|
+
* Supervisor читает их и решает: respawn vs notify-operator-and-stop.
|
|
189
|
+
*/
|
|
190
|
+
healthEvents(): AsyncIterable<UserbotHealthEvent> {
|
|
191
|
+
const self = this;
|
|
192
|
+
return {
|
|
193
|
+
[Symbol.asyncIterator](): AsyncIterator<UserbotHealthEvent> {
|
|
194
|
+
return {
|
|
195
|
+
next(): Promise<IteratorResult<UserbotHealthEvent>> {
|
|
196
|
+
const queued = self.healthQueue.shift();
|
|
197
|
+
if (queued) return Promise.resolve({ value: queued, done: false });
|
|
198
|
+
if (self.closed) {
|
|
199
|
+
return Promise.resolve({
|
|
200
|
+
value: undefined as unknown as UserbotHealthEvent,
|
|
201
|
+
done: true,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return new Promise((resolve) => self.healthWaiters.push(resolve));
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private registerHandler(client: GramjsClient): void {
|
|
212
|
+
client.addEventHandler(async (event: NewMessageEvent) => {
|
|
213
|
+
const msg = event.message;
|
|
214
|
+
if (!msg) return;
|
|
215
|
+
if ("out" in msg && (msg as { out?: boolean }).out) return; // skip own outgoing
|
|
216
|
+
const inbound = this.eventToInbound(event);
|
|
217
|
+
if (inbound) {
|
|
218
|
+
// Кэш для downloadMedia — без него mediaRef из Inbound не резолвится
|
|
219
|
+
// обратно к Message-объекту (MTProto не даёт публичный file_id).
|
|
220
|
+
this.cacheMessage(inbound.externalUserId, inbound.externalMessageId, msg);
|
|
221
|
+
this.enqueue(inbound);
|
|
222
|
+
}
|
|
223
|
+
}, new NewMessage({}));
|
|
224
|
+
this.emitHealth({ status: "connected" });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private cacheMessage(externalUserId: string, externalMessageId: string, msg: unknown): void {
|
|
228
|
+
const key = `${externalUserId}:${externalMessageId}`;
|
|
229
|
+
this.recentMessages.set(key, msg);
|
|
230
|
+
if (this.recentMessages.size > TelegramUserbotAdapter.MAX_RECENT_MESSAGES) {
|
|
231
|
+
// Map preserves insertion order — drop oldest entries first.
|
|
232
|
+
const dropCount = this.recentMessages.size - TelegramUserbotAdapter.MAX_RECENT_MESSAGES;
|
|
233
|
+
const it = this.recentMessages.keys();
|
|
234
|
+
for (let i = 0; i < dropCount; i++) {
|
|
235
|
+
const next = it.next();
|
|
236
|
+
if (next.done) break;
|
|
237
|
+
this.recentMessages.delete(next.value);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private eventToInbound(event: NewMessageEvent): Inbound | null {
|
|
243
|
+
const msg = event.message;
|
|
244
|
+
if (!msg) return null;
|
|
245
|
+
const senderId = msg.senderId?.toString();
|
|
246
|
+
if (!senderId) return null;
|
|
247
|
+
|
|
248
|
+
// Игнорируем не-private (групповые чаты, каналы).
|
|
249
|
+
const peer = msg.peerId as { className?: string } | undefined;
|
|
250
|
+
if (peer?.className !== "PeerUser") return null;
|
|
251
|
+
|
|
252
|
+
const parts: InboundPart[] = [];
|
|
253
|
+
const text = (msg as { message?: string }).message ?? "";
|
|
254
|
+
if (text.length > 0 && !this.hasMedia(msg)) {
|
|
255
|
+
parts.push({ kind: "text", text });
|
|
256
|
+
} else if (this.hasMedia(msg)) {
|
|
257
|
+
const mediaPart = this.mediaToPart(msg, text);
|
|
258
|
+
if (mediaPart) parts.push(mediaPart);
|
|
259
|
+
}
|
|
260
|
+
if (parts.length === 0) return null;
|
|
261
|
+
|
|
262
|
+
const externalMessageId = ((msg as { id?: number }).id ?? 0).toString();
|
|
263
|
+
return {
|
|
264
|
+
channelId: this.id,
|
|
265
|
+
externalMessageId,
|
|
266
|
+
externalUserId: senderId,
|
|
267
|
+
parts,
|
|
268
|
+
receivedAt: Math.floor(Date.now() / 1000),
|
|
269
|
+
raw: event,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private hasMedia(msg: unknown): boolean {
|
|
274
|
+
const m = msg as { photo?: unknown; video?: unknown; voice?: unknown; document?: unknown };
|
|
275
|
+
return !!(m.photo || m.video || m.voice || m.document);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private mediaToPart(msg: unknown, caption: string): InboundPart | null {
|
|
279
|
+
// MTProto media не имеют публичного file_id (как BotAPI) — instead мы
|
|
280
|
+
// храним сам msg.id как ref, чтобы потом downloadMedia(msg) мог
|
|
281
|
+
// достать байты через gramjs.
|
|
282
|
+
const m = msg as {
|
|
283
|
+
photo?: unknown;
|
|
284
|
+
video?: { duration?: number; mimeType?: string };
|
|
285
|
+
voice?: { duration?: number };
|
|
286
|
+
document?: { mimeType?: string; attributes?: Array<{ fileName?: string }> };
|
|
287
|
+
id: number;
|
|
288
|
+
};
|
|
289
|
+
const ref: MediaRef = { channelId: this.id, externalRef: String(m.id) };
|
|
290
|
+
|
|
291
|
+
if (m.photo) {
|
|
292
|
+
return {
|
|
293
|
+
kind: "photo",
|
|
294
|
+
mediaRef: ref,
|
|
295
|
+
...(caption ? { caption } : {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (m.video) {
|
|
299
|
+
return {
|
|
300
|
+
kind: "video",
|
|
301
|
+
mediaRef: ref,
|
|
302
|
+
...(caption ? { caption } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (m.voice) {
|
|
306
|
+
return {
|
|
307
|
+
kind: "voice",
|
|
308
|
+
mediaRef: ref,
|
|
309
|
+
...(m.voice.duration ? { durationSec: m.voice.duration } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (m.document) {
|
|
313
|
+
const fileName = m.document.attributes?.find((a) => a.fileName)?.fileName;
|
|
314
|
+
return {
|
|
315
|
+
kind: "document",
|
|
316
|
+
mediaRef: ref,
|
|
317
|
+
...(m.document.mimeType ? { mimeType: m.document.mimeType } : {}),
|
|
318
|
+
...(fileName ? { fileName } : {}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- ChannelAdapter методы ---------------------------------------------
|
|
325
|
+
|
|
326
|
+
private enqueue(inbound: Inbound): void {
|
|
327
|
+
const waiter = this.waiters.shift();
|
|
328
|
+
if (waiter) {
|
|
329
|
+
waiter({ value: inbound, done: false });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.inbox.push(inbound);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async close(): Promise<void> {
|
|
336
|
+
this.closed = true;
|
|
337
|
+
while (this.waiters.length > 0) {
|
|
338
|
+
const w = this.waiters.shift();
|
|
339
|
+
w?.({ value: undefined as unknown as Inbound, done: true });
|
|
340
|
+
}
|
|
341
|
+
while (this.healthWaiters.length > 0) {
|
|
342
|
+
const w = this.healthWaiters.shift();
|
|
343
|
+
w?.({ value: undefined as unknown as UserbotHealthEvent, done: true });
|
|
344
|
+
}
|
|
345
|
+
this.recentMessages.clear();
|
|
346
|
+
if (this.client) {
|
|
347
|
+
try {
|
|
348
|
+
await this.client.disconnect();
|
|
349
|
+
} catch {
|
|
350
|
+
// ignore — process restart всё равно очистит
|
|
351
|
+
}
|
|
352
|
+
this.client = null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
receive(signal?: AbortSignal): AsyncIterable<Inbound> {
|
|
357
|
+
const self = this;
|
|
358
|
+
return {
|
|
359
|
+
[Symbol.asyncIterator](): AsyncIterator<Inbound> {
|
|
360
|
+
return {
|
|
361
|
+
next(): Promise<IteratorResult<Inbound>> {
|
|
362
|
+
if (signal?.aborted) {
|
|
363
|
+
return Promise.resolve({ value: undefined as unknown as Inbound, done: true });
|
|
364
|
+
}
|
|
365
|
+
const queued = self.inbox.shift();
|
|
366
|
+
if (queued) {
|
|
367
|
+
return Promise.resolve({ value: queued, done: false });
|
|
368
|
+
}
|
|
369
|
+
if (self.closed) {
|
|
370
|
+
return Promise.resolve({ value: undefined as unknown as Inbound, done: true });
|
|
371
|
+
}
|
|
372
|
+
return new Promise((resolve) => {
|
|
373
|
+
self.waiters.push(resolve);
|
|
374
|
+
if (signal) {
|
|
375
|
+
signal.addEventListener(
|
|
376
|
+
"abort",
|
|
377
|
+
() => {
|
|
378
|
+
const idx = self.waiters.indexOf(resolve);
|
|
379
|
+
if (idx >= 0) self.waiters.splice(idx, 1);
|
|
380
|
+
resolve({ value: undefined as unknown as Inbound, done: true });
|
|
381
|
+
},
|
|
382
|
+
{ once: true },
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async send(envelope: OutboundEnvelope): Promise<Sent> {
|
|
393
|
+
if (!this.client) {
|
|
394
|
+
throw new Error("[telegram-userbot] send called before connect()");
|
|
395
|
+
}
|
|
396
|
+
if (envelope.parts.length === 0) {
|
|
397
|
+
throw new Error("OutboundEnvelope.parts must be non-empty");
|
|
398
|
+
}
|
|
399
|
+
const peer = await this.resolvePeer(envelope.externalUserId);
|
|
400
|
+
let firstId: number | undefined;
|
|
401
|
+
for (const part of envelope.parts) {
|
|
402
|
+
// MTProto.sendMessage принимает text-only через { message: ... }, файлы
|
|
403
|
+
// через { file: ... }. Здесь только text — media-send из БД-стора
|
|
404
|
+
// (file:path) добавляется отдельной миграцией если понадобится.
|
|
405
|
+
let sentId: number | undefined;
|
|
406
|
+
if (part.kind === "text") {
|
|
407
|
+
const result = await this.client.sendMessage(
|
|
408
|
+
peer as Parameters<GramjsClient["sendMessage"]>[0],
|
|
409
|
+
{
|
|
410
|
+
message: part.text,
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
sentId = (result as { id?: number }).id;
|
|
414
|
+
} else {
|
|
415
|
+
// Send-by-mediaRef не поддерживается в минимальной реализации —
|
|
416
|
+
// legacy-codebase использует filePath из медиа-кэша. Для платформы это
|
|
417
|
+
// добавляется когда worker'у понадобится re-relay медиа.
|
|
418
|
+
throw new Error(`[telegram-userbot] send: unsupported part.kind=${part.kind}`);
|
|
419
|
+
}
|
|
420
|
+
if (firstId === undefined) firstId = sentId;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
channelId: this.id,
|
|
425
|
+
externalMessageId: String(firstId ?? ""),
|
|
426
|
+
sentAt: Math.floor(Date.now() / 1000),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async edit(_opts: EditOpts): Promise<void> {
|
|
431
|
+
throw new Error("[telegram-userbot] edit: capability disabled (MTProto edit pending)");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async delete(opts: DeleteOpts): Promise<void> {
|
|
435
|
+
if (!this.client) {
|
|
436
|
+
throw new Error("[telegram-userbot] delete called before connect()");
|
|
437
|
+
}
|
|
438
|
+
const peer = await this.resolvePeer(opts.externalUserId);
|
|
439
|
+
const messageId = Number(opts.externalMessageId);
|
|
440
|
+
if (Number.isNaN(messageId)) {
|
|
441
|
+
throw new Error("[telegram-userbot] delete: externalMessageId must be numeric");
|
|
442
|
+
}
|
|
443
|
+
await this.client.deleteMessages(
|
|
444
|
+
peer as Parameters<GramjsClient["deleteMessages"]>[0],
|
|
445
|
+
[messageId],
|
|
446
|
+
{ revoke: true },
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Скачивает медиа через cached Message-объект. mediaRef.externalRef —
|
|
452
|
+
* msg.id из last-received Inbound; caller вызывает downloadMedia сразу
|
|
453
|
+
* после inbound'а (worker pipe'ит в media-кэш).
|
|
454
|
+
*
|
|
455
|
+
* mediaRef.externalRef сам по себе не хватает (gramjs.downloadMedia
|
|
456
|
+
* требует Message), поэтому мы кэшируем последние MAX_RECENT_MESSAGES
|
|
457
|
+
* объектов в recentMessages Map при registerHandler.
|
|
458
|
+
*
|
|
459
|
+
* Throws если: client не подключён / mediaRef не найден в кэше /
|
|
460
|
+
* downloadMedia ошибается.
|
|
461
|
+
*/
|
|
462
|
+
async downloadMedia(mediaRef: MediaRef, opts?: { externalUserId?: string }): Promise<Response> {
|
|
463
|
+
if (!this.client) {
|
|
464
|
+
throw new Error("[telegram-userbot] downloadMedia called before connect()");
|
|
465
|
+
}
|
|
466
|
+
if (!opts?.externalUserId) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
"[telegram-userbot] downloadMedia requires opts.externalUserId — pass it from Inbound.externalUserId",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const key = `${opts.externalUserId}:${mediaRef.externalRef}`;
|
|
472
|
+
const cached = this.recentMessages.get(key);
|
|
473
|
+
if (!cached) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`[telegram-userbot] downloadMedia: msg ${key} evicted from cache (>${TelegramUserbotAdapter.MAX_RECENT_MESSAGES} messages ago); download immediately on inbound`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
// gramjs.downloadMedia принимает Message + опции и возвращает Buffer.
|
|
479
|
+
const bytes = (await this.client.downloadMedia(
|
|
480
|
+
cached as Parameters<GramjsClient["downloadMedia"]>[0],
|
|
481
|
+
)) as Buffer | Uint8Array | string | null;
|
|
482
|
+
if (!bytes) {
|
|
483
|
+
throw new Error("[telegram-userbot] downloadMedia: empty result");
|
|
484
|
+
}
|
|
485
|
+
const buf = typeof bytes === "string" ? Buffer.from(bytes) : Buffer.from(bytes);
|
|
486
|
+
return new Response(buf);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async signalTyping(externalUserId: string): Promise<void> {
|
|
490
|
+
if (!this.client) return;
|
|
491
|
+
try {
|
|
492
|
+
const peer = await this.resolvePeer(externalUserId);
|
|
493
|
+
// Raw Api.messages.SetTyping. action=SendMessageTypingAction — indicator
|
|
494
|
+
// погаснет автоматически через ~6 сек у клиента, либо при send'е
|
|
495
|
+
// следующего сообщения.
|
|
496
|
+
// biome-ignore lint/suspicious/noExplicitAny: raw gramjs invoke API
|
|
497
|
+
await (this.client as any).invoke(
|
|
498
|
+
new Api.messages.SetTyping({
|
|
499
|
+
peer: peer as Parameters<GramjsClient["sendMessage"]>[0],
|
|
500
|
+
action: new Api.SendMessageTypingAction(),
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
} catch {
|
|
504
|
+
// typing-индикатор optional, не ломаем pipeline если не получилось.
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Резолв peer'а по external_user_id (string из Inbound). */
|
|
509
|
+
private async resolvePeer(externalUserId: string): Promise<unknown> {
|
|
510
|
+
if (!this.client) throw new Error("client not connected");
|
|
511
|
+
const userId = Number(externalUserId);
|
|
512
|
+
if (Number.isNaN(userId)) {
|
|
513
|
+
throw new Error(`[telegram-userbot] invalid externalUserId "${externalUserId}"`);
|
|
514
|
+
}
|
|
515
|
+
return this.client.getInputEntity(userId);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Api, TelegramClient } from "telegram";
|
|
2
|
+
import { computeCheck } from "telegram/Password.js";
|
|
3
|
+
import { StringSession } from "telegram/sessions/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Пошаговый MTProto-логин для onboarding'а personal-account userbot'а.
|
|
7
|
+
*
|
|
8
|
+
* GramJS высокоуровневый `client.start()` интерактивен (callback'и для кода
|
|
9
|
+
* и пароля) — не годится для stateless HTTP. Здесь логин разбит на шаги:
|
|
10
|
+
* 1. startUserbotLogin(phone) → connect + sendCode → { client, phoneCodeHash }
|
|
11
|
+
* 2. submitUserbotCode({client, code}) → auth.SignIn; при 2FA → needs2fa:true
|
|
12
|
+
* 3. submitUserbot2fa({client, password}) → account.GetPassword + SRP CheckPassword
|
|
13
|
+
*
|
|
14
|
+
* Между шагами вызывающая сторона (apps/api) ДОЛЖНА держать тот же `client`
|
|
15
|
+
* в памяти — phoneCodeHash и auth-key привязаны к этому live-соединению.
|
|
16
|
+
* По завершении session-string сохраняется (encrypted) и адаптер reconnect'ится.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type UserbotLoginErrorCode =
|
|
20
|
+
| "phone_invalid"
|
|
21
|
+
| "code_invalid"
|
|
22
|
+
| "code_expired"
|
|
23
|
+
| "password_invalid"
|
|
24
|
+
| "flood_wait"
|
|
25
|
+
| "unknown";
|
|
26
|
+
|
|
27
|
+
export class UserbotLoginError extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
readonly code: UserbotLoginErrorCode,
|
|
30
|
+
message: string,
|
|
31
|
+
/** Для flood_wait — сколько секунд ждать. */
|
|
32
|
+
readonly retryAfterSec?: number,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "UserbotLoginError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StartedUserbotLogin {
|
|
40
|
+
client: TelegramClient;
|
|
41
|
+
phoneCodeHash: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FinishedUserbotLogin {
|
|
45
|
+
sessionString: string;
|
|
46
|
+
userId: string;
|
|
47
|
+
username: string | null;
|
|
48
|
+
phone: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SESSION_PASSWORD_NEEDED = /SESSION_PASSWORD_NEEDED/i;
|
|
52
|
+
|
|
53
|
+
function mapRpcError(err: unknown): UserbotLoginError {
|
|
54
|
+
const msg =
|
|
55
|
+
err instanceof Error
|
|
56
|
+
? ((err as { errorMessage?: string }).errorMessage ?? err.message)
|
|
57
|
+
: String(err);
|
|
58
|
+
const floodMatch = /FLOOD_WAIT_(\d+)/i.exec(msg);
|
|
59
|
+
if (floodMatch) {
|
|
60
|
+
return new UserbotLoginError(
|
|
61
|
+
"flood_wait",
|
|
62
|
+
`Слишком много попыток — подождите ${floodMatch[1]} сек`,
|
|
63
|
+
Number.parseInt(floodMatch[1] ?? "0", 10),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (/PHONE_NUMBER_INVALID|PHONE_NUMBER_BANNED|PHONE_NUMBER_UNOCCUPIED/i.test(msg)) {
|
|
67
|
+
return new UserbotLoginError("phone_invalid", "Неверный номер телефона");
|
|
68
|
+
}
|
|
69
|
+
if (/PHONE_CODE_EXPIRED/i.test(msg)) {
|
|
70
|
+
return new UserbotLoginError("code_expired", "Код истёк — запросите новый");
|
|
71
|
+
}
|
|
72
|
+
if (/PHONE_CODE_INVALID|PHONE_CODE_EMPTY/i.test(msg)) {
|
|
73
|
+
return new UserbotLoginError("code_invalid", "Неверный код");
|
|
74
|
+
}
|
|
75
|
+
if (/PASSWORD_HASH_INVALID/i.test(msg)) {
|
|
76
|
+
return new UserbotLoginError("password_invalid", "Неверный пароль 2FA");
|
|
77
|
+
}
|
|
78
|
+
return new UserbotLoginError("unknown", msg);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Шаг 1: подключиться и отправить код подтверждения на номер. */
|
|
82
|
+
export async function startUserbotLogin(opts: {
|
|
83
|
+
apiId: number;
|
|
84
|
+
apiHash: string;
|
|
85
|
+
phone: string;
|
|
86
|
+
}): Promise<StartedUserbotLogin> {
|
|
87
|
+
const client = new TelegramClient(new StringSession(""), opts.apiId, opts.apiHash, {
|
|
88
|
+
connectionRetries: 3,
|
|
89
|
+
});
|
|
90
|
+
await client.connect();
|
|
91
|
+
try {
|
|
92
|
+
const { phoneCodeHash } = await client.sendCode(
|
|
93
|
+
{ apiId: opts.apiId, apiHash: opts.apiHash },
|
|
94
|
+
opts.phone,
|
|
95
|
+
);
|
|
96
|
+
return { client, phoneCodeHash };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
await client.disconnect().catch(() => {});
|
|
99
|
+
throw mapRpcError(err);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Шаг 2: отправить код. Если у аккаунта включён 2FA — Telegram вернёт
|
|
105
|
+
* SESSION_PASSWORD_NEEDED, возвращаем { needs2fa: true } (логин не завершён).
|
|
106
|
+
* Иначе логин завершён — возвращаем session-string.
|
|
107
|
+
*/
|
|
108
|
+
export async function submitUserbotCode(opts: {
|
|
109
|
+
client: TelegramClient;
|
|
110
|
+
phone: string;
|
|
111
|
+
phoneCodeHash: string;
|
|
112
|
+
code: string;
|
|
113
|
+
}): Promise<{ needs2fa: true } | ({ needs2fa: false } & FinishedUserbotLogin)> {
|
|
114
|
+
try {
|
|
115
|
+
await opts.client.invoke(
|
|
116
|
+
new Api.auth.SignIn({
|
|
117
|
+
phoneNumber: opts.phone,
|
|
118
|
+
phoneCodeHash: opts.phoneCodeHash,
|
|
119
|
+
phoneCode: opts.code,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const msg =
|
|
124
|
+
err instanceof Error
|
|
125
|
+
? ((err as { errorMessage?: string }).errorMessage ?? err.message)
|
|
126
|
+
: String(err);
|
|
127
|
+
if (SESSION_PASSWORD_NEEDED.test(msg)) {
|
|
128
|
+
return { needs2fa: true };
|
|
129
|
+
}
|
|
130
|
+
throw mapRpcError(err);
|
|
131
|
+
}
|
|
132
|
+
return { needs2fa: false, ...(await finishUserbotLogin(opts.client)) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Шаг 3 (опционально): отправить пароль 2FA через SRP. */
|
|
136
|
+
export async function submitUserbot2fa(opts: {
|
|
137
|
+
client: TelegramClient;
|
|
138
|
+
password: string;
|
|
139
|
+
}): Promise<FinishedUserbotLogin> {
|
|
140
|
+
try {
|
|
141
|
+
const pwd = await opts.client.invoke(new Api.account.GetPassword());
|
|
142
|
+
const input = await computeCheck(pwd, opts.password);
|
|
143
|
+
await opts.client.invoke(new Api.auth.CheckPassword({ password: input }));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
throw mapRpcError(err);
|
|
146
|
+
}
|
|
147
|
+
return finishUserbotLogin(opts.client);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Сохранить session-string + получить идентификаторы аккаунта. */
|
|
151
|
+
export async function finishUserbotLogin(client: TelegramClient): Promise<FinishedUserbotLogin> {
|
|
152
|
+
const me = (await client.getMe()) as Api.User;
|
|
153
|
+
const sessionString = client.session.save() as unknown as string;
|
|
154
|
+
return {
|
|
155
|
+
sessionString,
|
|
156
|
+
userId: String(me.id),
|
|
157
|
+
username: me.username ?? null,
|
|
158
|
+
phone: me.phone ?? null,
|
|
159
|
+
};
|
|
160
|
+
}
|