@imessaging/telegram-mtproto 0.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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@imessaging/telegram-mtproto",
3
+ "version": "0.1.0",
4
+ "description": "Telegram MTProto user-account transport for imessaging",
5
+ "keywords": [
6
+ "messaging",
7
+ "mtproto",
8
+ "telegram",
9
+ "transport",
10
+ "userbot"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/iconicompany/imessaging.git",
16
+ "directory": "packages/telegram-mtproto"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "exports": {
24
+ ".": "./src/index.ts"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "type-check": "bunx tsgo --noEmit -p tsconfig.json",
31
+ "test": "bun test"
32
+ },
33
+ "dependencies": {
34
+ "@imessaging/core": "0.1.0",
35
+ "big-integer": "^1.6.52"
36
+ },
37
+ "peerDependencies": {
38
+ "telegram": ">=1.224.1"
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export {
2
+ TelegramMtprotoTransport,
3
+ type TelegramMtprotoTransportOptions,
4
+ } from "./telegram-mtproto-transport";
@@ -0,0 +1,235 @@
1
+ import type {
2
+ MessageRecipient,
3
+ MessageTransport,
4
+ OutboundMessage,
5
+ ResolvedPeer,
6
+ SendResult,
7
+ TelegramPeerStore,
8
+ TransportStatus,
9
+ } from "@imessaging/core";
10
+ import { MemoryPeerStore } from "@imessaging/core";
11
+ import bigInt from "big-integer";
12
+ import { Api, TelegramClient } from "telegram";
13
+ import { CustomFile } from "telegram/client/uploads";
14
+ import type { TelegramClientParams } from "telegram/client/telegramBaseClient";
15
+ import { StringSession } from "telegram/sessions";
16
+
17
+ export type TelegramMtprotoTransportOptions = {
18
+ accountId: string;
19
+ apiId: number;
20
+ apiHash: string;
21
+ session: string;
22
+ connectionRetries?: number;
23
+ clientOptions?: TelegramClientParams;
24
+ peerStore?: TelegramPeerStore;
25
+ peerTtlSeconds?: number;
26
+ };
27
+
28
+ type TelegramRpcError = {
29
+ code?: number;
30
+ errorMessage?: string;
31
+ };
32
+
33
+ const INVALID_PEER_ERRORS = new Set([
34
+ "PEER_ID_INVALID",
35
+ "ACCESS_TOKEN_EXPIRED",
36
+ "PEER_ID_NOT_SUPPORTED",
37
+ ]);
38
+
39
+ function isPeerUser(peer: Api.TypePeer): peer is Api.PeerUser {
40
+ return peer.className === "PeerUser";
41
+ }
42
+
43
+ function isPeerChannel(peer: Api.TypePeer): peer is Api.PeerChannel {
44
+ return peer.className === "PeerChannel";
45
+ }
46
+
47
+ function isUser(user: Api.TypeUser): user is Api.User {
48
+ return user.className === "User";
49
+ }
50
+
51
+ function isChannel(chat: Api.TypeChat): chat is Api.Channel {
52
+ return chat.className === "Channel";
53
+ }
54
+
55
+ export class TelegramMtprotoTransport implements MessageTransport {
56
+ readonly id: string;
57
+ private readonly client: TelegramClient;
58
+ private readonly peerStore: TelegramPeerStore;
59
+ private connected = false;
60
+ private lastActivityAt?: Date;
61
+
62
+ constructor(private readonly options: TelegramMtprotoTransportOptions) {
63
+ if (!options.accountId.trim()) throw new Error("accountId must not be empty");
64
+
65
+ this.id = `telegram-mtproto:${options.accountId}`;
66
+ this.peerStore = options.peerStore ?? new MemoryPeerStore();
67
+ if (!Number.isInteger(options.apiId) || options.apiId <= 0) {
68
+ throw new Error("apiId must be a positive integer");
69
+ }
70
+ if (!options.apiHash.trim()) throw new Error("apiHash must not be empty");
71
+ if (!options.session.trim()) throw new Error("session must not be empty");
72
+ this.client = new TelegramClient(
73
+ new StringSession(options.session),
74
+ options.apiId,
75
+ options.apiHash,
76
+ {
77
+ ...options.clientOptions,
78
+ connectionRetries:
79
+ options.connectionRetries ?? options.clientOptions?.connectionRetries ?? 5,
80
+ },
81
+ );
82
+ }
83
+
84
+ async connect(): Promise<void> {
85
+ await this.client.connect();
86
+ this.connected = true;
87
+ }
88
+
89
+ async disconnect(): Promise<void> {
90
+ await this.client.disconnect();
91
+ this.connected = false;
92
+ }
93
+
94
+ async send(message: OutboundMessage): Promise<SendResult> {
95
+ this.assertConnected();
96
+ return this.sendWithRetry(message, false);
97
+ }
98
+
99
+ async getStatus(): Promise<TransportStatus> {
100
+ return {
101
+ connected: this.connected && Boolean(this.client.connected),
102
+ transportId: this.id,
103
+ accountId: this.options.accountId,
104
+ lastActivityAt: this.lastActivityAt,
105
+ };
106
+ }
107
+
108
+ private async sendWithRetry(message: OutboundMessage, retried: boolean): Promise<SendResult> {
109
+ try {
110
+ const peer = await this.getInputPeer(message.recipient);
111
+ const sent = await this.client.sendMessage(peer, {
112
+ message: message.text,
113
+ parseMode: message.parseMode?.toLowerCase(),
114
+ replyTo: message.replyToMessageId ?? message.threadId,
115
+ });
116
+ for (const document of message.documents ?? []) {
117
+ const data = Buffer.from(document.data);
118
+ await this.client.sendFile(peer, {
119
+ file: new CustomFile(document.filename, data.length, "", data),
120
+ caption: document.caption,
121
+ replyTo: message.threadId,
122
+ });
123
+ }
124
+ this.lastActivityAt = new Date();
125
+ return {
126
+ transportId: this.id,
127
+ messageId: String(sent.id),
128
+ recipientId: this.getPeerId(peer),
129
+ };
130
+ } catch (error: unknown) {
131
+ if (retried || !this.isInvalidPeerError(error)) throw error;
132
+ await this.peerStore.delete(this.options.accountId, message.recipient);
133
+ return this.sendWithRetry(message, true);
134
+ }
135
+ }
136
+
137
+ private async getInputPeer(recipient: MessageRecipient): Promise<Api.TypeInputPeer> {
138
+ const cached = await this.peerStore.get(this.options.accountId, recipient);
139
+ if (cached) return this.toInputPeer(cached);
140
+
141
+ const resolved = await this.resolvePeer(recipient);
142
+ await this.peerStore.set(
143
+ this.options.accountId,
144
+ recipient,
145
+ resolved,
146
+ this.options.peerTtlSeconds,
147
+ );
148
+ return this.toInputPeer(resolved);
149
+ }
150
+
151
+ private async resolvePeer(recipient: MessageRecipient): Promise<ResolvedPeer> {
152
+ if (recipient.type !== "username" && !recipient.username) {
153
+ throw new Error(
154
+ `Cannot resolve uncached Telegram ${recipient.type} ${recipient.id} without a username`,
155
+ );
156
+ }
157
+ const username = recipient.username;
158
+ if (!username) {
159
+ throw new Error(`Cannot resolve Telegram ${recipient.type} without a username`);
160
+ }
161
+ const cleanUsername = username.replace(/^@/, "");
162
+ const result = await this.client.invoke(
163
+ new Api.contacts.ResolveUsername({ username: cleanUsername }),
164
+ );
165
+
166
+ if (isPeerUser(result.peer)) {
167
+ const peer = result.peer;
168
+ const user = result.users.find(
169
+ (candidate): candidate is Api.User => isUser(candidate) && candidate.id.equals(peer.userId),
170
+ );
171
+ if (!user?.accessHash) throw new Error(`No accessHash returned for @${cleanUsername}`);
172
+ return {
173
+ type: "user",
174
+ id: user.id.toString(),
175
+ accessHash: user.accessHash.toString(),
176
+ username: cleanUsername,
177
+ resolvedAt: new Date().toISOString(),
178
+ };
179
+ }
180
+
181
+ if (isPeerChannel(result.peer)) {
182
+ const peer = result.peer;
183
+ const channel = result.chats.find(
184
+ (candidate): candidate is Api.Channel =>
185
+ isChannel(candidate) && candidate.id.equals(peer.channelId),
186
+ );
187
+ if (!channel?.accessHash) throw new Error(`No accessHash returned for @${cleanUsername}`);
188
+ return {
189
+ type: "channel",
190
+ id: channel.id.toString(),
191
+ accessHash: channel.accessHash.toString(),
192
+ username: cleanUsername,
193
+ resolvedAt: new Date().toISOString(),
194
+ };
195
+ }
196
+
197
+ throw new Error(`@${cleanUsername} cannot be resolved as a user or channel`);
198
+ }
199
+
200
+ private toInputPeer(peer: ResolvedPeer): Api.TypeInputPeer {
201
+ if (peer.type === "group") {
202
+ return new Api.InputPeerChat({ chatId: bigInt(peer.id) });
203
+ }
204
+ if (!peer.accessHash) throw new Error(`Cached ${peer.type} peer has no accessHash`);
205
+ if (peer.type === "user") {
206
+ return new Api.InputPeerUser({
207
+ userId: bigInt(peer.id),
208
+ accessHash: bigInt(peer.accessHash),
209
+ });
210
+ }
211
+ return new Api.InputPeerChannel({
212
+ channelId: bigInt(peer.id),
213
+ accessHash: bigInt(peer.accessHash),
214
+ });
215
+ }
216
+
217
+ private getPeerId(peer: Api.TypeInputPeer): string {
218
+ if (peer instanceof Api.InputPeerUser) return peer.userId.toString();
219
+ if (peer instanceof Api.InputPeerChat) return peer.chatId.toString();
220
+ if (peer instanceof Api.InputPeerChannel) return peer.channelId.toString();
221
+ throw new Error(`Unsupported input peer: ${peer.className}`);
222
+ }
223
+
224
+ private assertConnected(): void {
225
+ if (!this.connected || !this.client.connected) {
226
+ throw new Error(`${this.id} is not connected`);
227
+ }
228
+ }
229
+
230
+ private isInvalidPeerError(error: unknown): boolean {
231
+ if (typeof error !== "object" || error === null) return false;
232
+ const telegramError = error as TelegramRpcError;
233
+ return telegramError.code === 400 && INVALID_PEER_ERRORS.has(telegramError.errorMessage ?? "");
234
+ }
235
+ }