@bruch/max-client 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/LICENSE +22 -0
- package/README.md +168 -0
- package/UPSTREAM.md +10 -0
- package/dist/api/account.d.ts +24 -0
- package/dist/api/account.d.ts.map +1 -0
- package/dist/api/auth.d.ts +57 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/bots.d.ts +8 -0
- package/dist/api/bots.d.ts.map +1 -0
- package/dist/api/chats.d.ts +41 -0
- package/dist/api/chats.d.ts.map +1 -0
- package/dist/api/facade.d.ts +19 -0
- package/dist/api/facade.d.ts.map +1 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/messages.d.ts +35 -0
- package/dist/api/messages.d.ts.map +1 -0
- package/dist/api/response.d.ts +7 -0
- package/dist/api/response.d.ts.map +1 -0
- package/dist/api/runtime.d.ts +19 -0
- package/dist/api/runtime.d.ts.map +1 -0
- package/dist/api/uploads.d.ts +23 -0
- package/dist/api/uploads.d.ts.map +1 -0
- package/dist/api/users.d.ts +25 -0
- package/dist/api/users.d.ts.map +1 -0
- package/dist/app.d.ts +55 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/auth/flows.d.ts +27 -0
- package/dist/auth/flows.d.ts.map +1 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +109 -0
- package/dist/auth/index.js.map +11 -0
- package/dist/auth/providers.d.ts +11 -0
- package/dist/auth/providers.d.ts.map +1 -0
- package/dist/client.d.ts +139 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/config.d.ts +80 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/connection/connection.d.ts +25 -0
- package/dist/connection/connection.d.ts.map +1 -0
- package/dist/connection/index.d.ts +4 -0
- package/dist/connection/index.d.ts.map +1 -0
- package/dist/connection/pending.d.ts +9 -0
- package/dist/connection/pending.d.ts.map +1 -0
- package/dist/connection/readers.d.ts +17 -0
- package/dist/connection/readers.d.ts.map +1 -0
- package/dist/dispatch/dispatcher.d.ts +16 -0
- package/dist/dispatch/dispatcher.d.ts.map +1 -0
- package/dist/dispatch/index.d.ts +3 -0
- package/dist/dispatch/index.d.ts.map +1 -0
- package/dist/dispatch/router.d.ts +64 -0
- package/dist/dispatch/router.d.ts.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/files/file.d.ts +29 -0
- package/dist/files/file.d.ts.map +1 -0
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +95 -0
- package/dist/files/index.js.map +10 -0
- package/dist/formatting/index.d.ts +2 -0
- package/dist/formatting/index.d.ts.map +1 -0
- package/dist/formatting/markdown.d.ts +13 -0
- package/dist/formatting/markdown.d.ts.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3967 -0
- package/dist/index.js.map +48 -0
- package/dist/logging.d.ts +10 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/protocol/compression.d.ts +8 -0
- package/dist/protocol/compression.d.ts.map +1 -0
- package/dist/protocol/enums.d.ts +175 -0
- package/dist/protocol/enums.d.ts.map +1 -0
- package/dist/protocol/index.d.ts +6 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +438 -0
- package/dist/protocol/index.js.map +13 -0
- package/dist/protocol/tcp.d.ts +35 -0
- package/dist/protocol/tcp.d.ts.map +1 -0
- package/dist/protocol/types.d.ts +38 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/websocket.d.ts +7 -0
- package/dist/protocol/websocket.d.ts.map +1 -0
- package/dist/session/index.d.ts +3 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +179 -0
- package/dist/session/index.js.map +12 -0
- package/dist/session/store.d.ts +18 -0
- package/dist/session/store.d.ts.map +1 -0
- package/dist/session/types.d.ts +29 -0
- package/dist/session/types.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +4 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +831 -0
- package/dist/telemetry/index.js.map +17 -0
- package/dist/telemetry/navigation.d.ts +41 -0
- package/dist/telemetry/navigation.d.ts.map +1 -0
- package/dist/telemetry/payloads.d.ts +30 -0
- package/dist/telemetry/payloads.d.ts.map +1 -0
- package/dist/telemetry/service.d.ts +53 -0
- package/dist/telemetry/service.d.ts.map +1 -0
- package/dist/transport/base.d.ts +18 -0
- package/dist/transport/base.d.ts.map +1 -0
- package/dist/transport/index.d.ts +4 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/tcp.d.ts +28 -0
- package/dist/transport/tcp.d.ts.map +1 -0
- package/dist/transport/websocket.d.ts +14 -0
- package/dist/transport/websocket.d.ts.map +1 -0
- package/dist/types/attachments.d.ts +18 -0
- package/dist/types/attachments.d.ts.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +376 -0
- package/dist/types/index.js.map +12 -0
- package/dist/types/models.d.ts +210 -0
- package/dist/types/models.d.ts.map +1 -0
- package/dist/types/utils.d.ts +10 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/package.json +95 -0
- package/src/api/account.ts +93 -0
- package/src/api/auth.ts +312 -0
- package/src/api/bots.ts +21 -0
- package/src/api/chats.ts +234 -0
- package/src/api/facade.ts +28 -0
- package/src/api/index.ts +9 -0
- package/src/api/messages.ts +230 -0
- package/src/api/response.ts +24 -0
- package/src/api/runtime.ts +27 -0
- package/src/api/uploads.ts +91 -0
- package/src/api/users.ts +68 -0
- package/src/app.ts +271 -0
- package/src/auth/flows.ts +103 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/providers.ts +30 -0
- package/src/client.ts +506 -0
- package/src/config.ts +161 -0
- package/src/connection/connection.ts +93 -0
- package/src/connection/index.ts +3 -0
- package/src/connection/pending.ts +49 -0
- package/src/connection/readers.ts +33 -0
- package/src/dispatch/dispatcher.ts +194 -0
- package/src/dispatch/index.ts +2 -0
- package/src/dispatch/router.ts +149 -0
- package/src/errors.ts +41 -0
- package/src/files/file.ts +92 -0
- package/src/files/index.ts +1 -0
- package/src/formatting/index.ts +1 -0
- package/src/formatting/markdown.ts +123 -0
- package/src/index.ts +15 -0
- package/src/logging.ts +67 -0
- package/src/protocol/compression.ts +69 -0
- package/src/protocol/enums.ts +175 -0
- package/src/protocol/index.ts +5 -0
- package/src/protocol/tcp.ts +139 -0
- package/src/protocol/types.ts +45 -0
- package/src/protocol/websocket.ts +47 -0
- package/src/session/index.ts +2 -0
- package/src/session/store.ts +154 -0
- package/src/session/types.ts +45 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/navigation.ts +160 -0
- package/src/telemetry/payloads.ts +84 -0
- package/src/telemetry/service.ts +194 -0
- package/src/transport/base.ts +43 -0
- package/src/transport/index.ts +3 -0
- package/src/transport/tcp.ts +296 -0
- package/src/transport/websocket.ts +63 -0
- package/src/types/attachments.ts +24 -0
- package/src/types/index.ts +3 -0
- package/src/types/models.ts +461 -0
- package/src/types/utils.ts +45 -0
package/src/api/users.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Opcode } from "../protocol/index.ts";
|
|
2
|
+
import { User, asRecord, type MaxId, type UserActions } from "../types/index.ts";
|
|
3
|
+
import { listItem, requiredItem } from "./response.ts";
|
|
4
|
+
import type { ApiRuntime } from "./runtime.ts";
|
|
5
|
+
|
|
6
|
+
export interface ContactInfo {
|
|
7
|
+
phone: string;
|
|
8
|
+
firstName: string;
|
|
9
|
+
lastName?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class UserApi implements UserActions {
|
|
13
|
+
private readonly cache = new Map<MaxId, User>();
|
|
14
|
+
constructor(private readonly app: ApiRuntime) {}
|
|
15
|
+
|
|
16
|
+
seed(users: Array<User | null>): void {
|
|
17
|
+
for (const user of users) if (user) this.cacheUser(user);
|
|
18
|
+
}
|
|
19
|
+
getCachedUser(userId: MaxId): User | null {
|
|
20
|
+
return this.cache.get(userId) ?? null;
|
|
21
|
+
}
|
|
22
|
+
async getUsers(userIds: MaxId[]): Promise<User[]> {
|
|
23
|
+
const missing = userIds.filter((id) => !this.cache.has(id));
|
|
24
|
+
if (missing.length > 0)
|
|
25
|
+
for (const user of await this.fetchUsers(missing)) this.cache.set(user.id, user);
|
|
26
|
+
return userIds.flatMap((id) => (this.cache.has(id) ? [this.cache.get(id) as User] : []));
|
|
27
|
+
}
|
|
28
|
+
async getUser(userId: MaxId): Promise<User | null> {
|
|
29
|
+
return (await this.getUsers([userId]))[0] ?? null;
|
|
30
|
+
}
|
|
31
|
+
async fetchUsers(userIds: MaxId[]): Promise<User[]> {
|
|
32
|
+
const frame = await this.app.invoke(Opcode.CONTACT_INFO, { contactIds: userIds });
|
|
33
|
+
return listItem(frame, "contacts").map((raw) => this.cacheUser(User.fromPayload(raw)));
|
|
34
|
+
}
|
|
35
|
+
async searchByPhone(phone: string): Promise<User> {
|
|
36
|
+
const frame = await this.app.invoke(Opcode.CONTACT_INFO_BY_PHONE, { phone });
|
|
37
|
+
return this.cacheUser(User.fromPayload(requiredItem(frame, "contact")));
|
|
38
|
+
}
|
|
39
|
+
async getSessions(): Promise<Record<string, unknown>[]> {
|
|
40
|
+
return listItem(await this.app.invoke(Opcode.SESSIONS_INFO, {}), "sessions").map((item) =>
|
|
41
|
+
asRecord(item),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
async addContact(contactId: MaxId): Promise<User> {
|
|
45
|
+
const frame = await this.app.invoke(Opcode.CONTACT_UPDATE, { contactId, action: "add" });
|
|
46
|
+
return this.cacheUser(User.fromPayload(requiredItem(frame, "contact")));
|
|
47
|
+
}
|
|
48
|
+
async removeContact(contactId: MaxId): Promise<boolean> {
|
|
49
|
+
await this.app.invoke(Opcode.CONTACT_UPDATE, { contactId, action: "remove" });
|
|
50
|
+
this.cache.delete(contactId);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
async importContacts(contacts: ContactInfo[]): Promise<User[]> {
|
|
54
|
+
const contactList = Object.fromEntries(
|
|
55
|
+
contacts.map((item) => [item.phone, { firstName: item.firstName }]),
|
|
56
|
+
);
|
|
57
|
+
const frame = await this.app.invoke(Opcode.SYNC, { contactList });
|
|
58
|
+
return listItem(frame, "contacts").map((raw) => this.cacheUser(User.fromPayload(raw)));
|
|
59
|
+
}
|
|
60
|
+
getChatId(firstUserId: MaxId, secondUserId: MaxId): MaxId {
|
|
61
|
+
return firstUserId ^ secondUserId;
|
|
62
|
+
}
|
|
63
|
+
private cacheUser(user: User): User {
|
|
64
|
+
const bound = this.app.bindUser(user);
|
|
65
|
+
this.cache.set(bound.id, bound);
|
|
66
|
+
return bound;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { ApiFacade, type ApiRuntime } from "./api/index.ts";
|
|
2
|
+
import type { AuthFlow } from "./auth/index.ts";
|
|
3
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
4
|
+
import { Dispatcher, type Router } from "./dispatch/index.ts";
|
|
5
|
+
import { ApiError } from "./errors.ts";
|
|
6
|
+
import { getLogger } from "./logging.ts";
|
|
7
|
+
import { Command, Opcode, type InboundFrame, type OutboundFrame } from "./protocol/index.ts";
|
|
8
|
+
import {
|
|
9
|
+
SqliteSessionStore,
|
|
10
|
+
defaultSyncState,
|
|
11
|
+
type SessionInfo,
|
|
12
|
+
type SessionStore,
|
|
13
|
+
} from "./session/index.ts";
|
|
14
|
+
import { TelemetryService } from "./telemetry/index.ts";
|
|
15
|
+
import type {
|
|
16
|
+
Chat,
|
|
17
|
+
ChatActions,
|
|
18
|
+
Message,
|
|
19
|
+
MessageActions,
|
|
20
|
+
Profile,
|
|
21
|
+
User,
|
|
22
|
+
UserActions,
|
|
23
|
+
} from "./types/index.ts";
|
|
24
|
+
|
|
25
|
+
export interface ConnectionContract {
|
|
26
|
+
readonly protocol: { readonly version: number };
|
|
27
|
+
readonly isOpen?: boolean;
|
|
28
|
+
onEvent: ((frame: InboundFrame) => void | Promise<void>) | null;
|
|
29
|
+
onClose: ((error: Error | null) => void) | null;
|
|
30
|
+
open(): Promise<void>;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
request(frame: OutboundFrame, timeoutSeconds?: number): Promise<InboundFrame>;
|
|
33
|
+
waitClosed(): Promise<void>;
|
|
34
|
+
nextSequence(): number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class App<Client extends MessageActions & ChatActions & UserActions> implements ApiRuntime {
|
|
38
|
+
readonly dispatcher: Dispatcher<Client>;
|
|
39
|
+
readonly api: ApiFacade;
|
|
40
|
+
readonly telemetry: TelemetryService;
|
|
41
|
+
readonly store: SessionStore;
|
|
42
|
+
session: SessionInfo | null = null;
|
|
43
|
+
me: Profile | null = null;
|
|
44
|
+
chats: Chat[] = [];
|
|
45
|
+
contacts: Array<User | null> = [];
|
|
46
|
+
messages = new Map<bigint, Message[]>();
|
|
47
|
+
started = false;
|
|
48
|
+
private pingController: AbortController | null = null;
|
|
49
|
+
private readonly attachmentWaiters = new Map<
|
|
50
|
+
string,
|
|
51
|
+
Set<{ resolve(): void; reject(error: Error): void }>
|
|
52
|
+
>();
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
readonly connection: ConnectionContract,
|
|
56
|
+
readonly config: ResolvedConfig,
|
|
57
|
+
readonly authFlow: AuthFlow,
|
|
58
|
+
rootRouter: Router<Client>,
|
|
59
|
+
) {
|
|
60
|
+
this.dispatcher = new Dispatcher(rootRouter);
|
|
61
|
+
this.store = config.store ?? new SqliteSessionStore(config.workDir, config.sessionName);
|
|
62
|
+
this.api = new ApiFacade(this);
|
|
63
|
+
this.telemetry = new TelemetryService(this);
|
|
64
|
+
this.connection.onEvent = async (frame) => {
|
|
65
|
+
this.resolveAttachmentWaiters(frame);
|
|
66
|
+
await this.dispatcher.dispatch(frame);
|
|
67
|
+
};
|
|
68
|
+
this.connection.onClose = (error) => this.connectionLost(error);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
bindClient(client: Client): void {
|
|
72
|
+
this.dispatcher.bindClient(client);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async start(): Promise<void> {
|
|
76
|
+
if (this.started) return;
|
|
77
|
+
let saved = await this.store.loadSession();
|
|
78
|
+
if (saved?.mtInstanceId) this.config.device.mtInstanceId = saved.mtInstanceId;
|
|
79
|
+
await this.connection.open();
|
|
80
|
+
await this.api.auth.handshake(saved?.deviceId ?? this.config.device.deviceId);
|
|
81
|
+
this.startPing();
|
|
82
|
+
|
|
83
|
+
if (!saved) {
|
|
84
|
+
const token = this.config.token ?? (await this.authFlow.authenticate(this)).token;
|
|
85
|
+
if (!token) throw new Error("Authentication failed: no token returned");
|
|
86
|
+
saved = {
|
|
87
|
+
token,
|
|
88
|
+
deviceId: this.config.device.deviceId,
|
|
89
|
+
phone: this.config.phone ?? "",
|
|
90
|
+
mtInstanceId: this.config.device.mtInstanceId,
|
|
91
|
+
sync: defaultSyncState(),
|
|
92
|
+
};
|
|
93
|
+
await this.store.saveSession(saved);
|
|
94
|
+
}
|
|
95
|
+
this.session = saved;
|
|
96
|
+
const login = await this.api.auth.login();
|
|
97
|
+
let activeSession = this.session ?? saved;
|
|
98
|
+
if (login.token && login.token !== activeSession.token) {
|
|
99
|
+
await this.store.updateToken(activeSession.token, login.token);
|
|
100
|
+
activeSession = { ...activeSession, token: login.token };
|
|
101
|
+
}
|
|
102
|
+
this.session = activeSession;
|
|
103
|
+
this.me = login.profile;
|
|
104
|
+
this.chats = login.chats;
|
|
105
|
+
this.contacts = login.contacts;
|
|
106
|
+
this.messages = login.messages;
|
|
107
|
+
this.api.chats.seed(this.chats);
|
|
108
|
+
this.api.users.seed([...this.contacts, this.me.contact]);
|
|
109
|
+
this.bindUser(this.me.contact);
|
|
110
|
+
this.started = true;
|
|
111
|
+
if (this.config.telemetry) this.telemetry.start();
|
|
112
|
+
getLogger().info("client started", {
|
|
113
|
+
profileId: this.me.contact.id.toString(),
|
|
114
|
+
chats: this.chats.length,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async close(): Promise<void> {
|
|
119
|
+
this.pingController?.abort();
|
|
120
|
+
this.pingController = null;
|
|
121
|
+
await this.telemetry.stop();
|
|
122
|
+
for (const entries of this.attachmentWaiters.values()) {
|
|
123
|
+
for (const entry of entries)
|
|
124
|
+
entry.reject(new Error("Client closed before upload processing completed"));
|
|
125
|
+
}
|
|
126
|
+
this.attachmentWaiters.clear();
|
|
127
|
+
await this.connection.close();
|
|
128
|
+
await this.store.close();
|
|
129
|
+
this.started = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async invoke(
|
|
133
|
+
opcode: number,
|
|
134
|
+
payload: Record<string, unknown> = {},
|
|
135
|
+
timeout?: number,
|
|
136
|
+
): Promise<InboundFrame> {
|
|
137
|
+
const frame: OutboundFrame = {
|
|
138
|
+
ver: this.connection.protocol.version,
|
|
139
|
+
opcode,
|
|
140
|
+
cmd: Command.REQUEST,
|
|
141
|
+
seq: this.connection.nextSequence(),
|
|
142
|
+
payload,
|
|
143
|
+
};
|
|
144
|
+
const response = await this.connection.request(frame, timeout ?? this.config.requestTimeout);
|
|
145
|
+
if (response.cmd === Command.ERROR) throw buildApiError(response);
|
|
146
|
+
return response;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
bindMessage(message: Message): Message {
|
|
150
|
+
return message.bind(this.api.messages);
|
|
151
|
+
}
|
|
152
|
+
bindChat(chat: Chat): Chat {
|
|
153
|
+
return chat.bind(this.api.messages, this.api.chats);
|
|
154
|
+
}
|
|
155
|
+
bindUser(user: User): User {
|
|
156
|
+
return user.bind(this.api.users);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
createAttachmentWaiter(
|
|
160
|
+
field: "fileId" | "videoId",
|
|
161
|
+
id: number,
|
|
162
|
+
timeoutSeconds = 60,
|
|
163
|
+
): { promise: Promise<void>; cancel(): void } {
|
|
164
|
+
const key = `${field}:${id}`;
|
|
165
|
+
const entries = this.attachmentWaiters.get(key) ?? new Set();
|
|
166
|
+
let active = true;
|
|
167
|
+
let resolvePromise: () => void = () => undefined;
|
|
168
|
+
let rejectPromise: (error: Error) => void = () => undefined;
|
|
169
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
170
|
+
resolvePromise = resolve;
|
|
171
|
+
rejectPromise = reject;
|
|
172
|
+
});
|
|
173
|
+
const entry = {
|
|
174
|
+
resolve: () => {
|
|
175
|
+
if (!active) return;
|
|
176
|
+
active = false;
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
entries.delete(entry);
|
|
179
|
+
resolvePromise();
|
|
180
|
+
},
|
|
181
|
+
reject: (error: Error) => {
|
|
182
|
+
if (!active) return;
|
|
183
|
+
active = false;
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
entries.delete(entry);
|
|
186
|
+
rejectPromise(error);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
entries.add(entry);
|
|
190
|
+
this.attachmentWaiters.set(key, entries);
|
|
191
|
+
const timer = setTimeout(
|
|
192
|
+
() => entry.reject(new Error(`Timed out waiting for ${field}=${id} processing`)),
|
|
193
|
+
timeoutSeconds * 1000,
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
promise,
|
|
197
|
+
cancel: () => {
|
|
198
|
+
if (!active) return;
|
|
199
|
+
active = false;
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
entries.delete(entry);
|
|
202
|
+
if (entries.size === 0) this.attachmentWaiters.delete(key);
|
|
203
|
+
promise.catch(() => undefined);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private startPing(): void {
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
this.pingController = controller;
|
|
211
|
+
void this.pingLoop(controller.signal);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async pingLoop(signal: AbortSignal): Promise<void> {
|
|
215
|
+
try {
|
|
216
|
+
while (!signal.aborted) {
|
|
217
|
+
await this.invoke(Opcode.PING, { interactive: true });
|
|
218
|
+
await sleep(30_000, signal);
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (!signal.aborted) {
|
|
222
|
+
getLogger().warn("ping failed", { error: String(error) });
|
|
223
|
+
await this.connection.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private connectionLost(error: Error | null): void {
|
|
229
|
+
this.started = false;
|
|
230
|
+
this.pingController?.abort();
|
|
231
|
+
if (error) getLogger().warn("connection lost", { error: error.message });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private resolveAttachmentWaiters(frame: InboundFrame): void {
|
|
235
|
+
if (frame.opcode !== Opcode.NOTIF_ATTACH || !frame.payload) return;
|
|
236
|
+
for (const field of ["fileId", "videoId"] as const) {
|
|
237
|
+
const value = frame.payload[field];
|
|
238
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) continue;
|
|
239
|
+
const key = `${field}:${value}`;
|
|
240
|
+
const entries = this.attachmentWaiters.get(key);
|
|
241
|
+
if (!entries) continue;
|
|
242
|
+
this.attachmentWaiters.delete(key);
|
|
243
|
+
for (const entry of entries) entry.resolve();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildApiError(frame: InboundFrame): ApiError {
|
|
249
|
+
const data = frame.payload ?? {};
|
|
250
|
+
return new ApiError(frame.opcode, {
|
|
251
|
+
error: typeof data.error === "string" ? data.error : null,
|
|
252
|
+
title: typeof data.title === "string" ? data.title : null,
|
|
253
|
+
message: typeof data.message === "string" ? data.message : null,
|
|
254
|
+
localizedMessage: typeof data.localizedMessage === "string" ? data.localizedMessage : null,
|
|
255
|
+
payload: data,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function sleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
260
|
+
await new Promise<void>((resolve, reject) => {
|
|
261
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
262
|
+
signal.addEventListener(
|
|
263
|
+
"abort",
|
|
264
|
+
() => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
reject(signal.reason);
|
|
267
|
+
},
|
|
268
|
+
{ once: true },
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ApiFacade } from "../api/index.ts";
|
|
2
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
3
|
+
import {
|
|
4
|
+
consolePasswordProvider,
|
|
5
|
+
consoleQrHandler,
|
|
6
|
+
consoleSmsCodeProvider,
|
|
7
|
+
type PasswordProvider,
|
|
8
|
+
type QrHandler,
|
|
9
|
+
type SmsCodeProvider,
|
|
10
|
+
} from "./providers.ts";
|
|
11
|
+
|
|
12
|
+
export interface AuthResult {
|
|
13
|
+
token: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AuthRuntime {
|
|
17
|
+
readonly api: ApiFacade;
|
|
18
|
+
readonly config: ResolvedConfig;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AuthFlow {
|
|
22
|
+
authenticate(app: AuthRuntime): Promise<AuthResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SmsAuthFlow implements AuthFlow {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly codeProvider: SmsCodeProvider = consoleSmsCodeProvider,
|
|
28
|
+
readonly passwordProvider: PasswordProvider = consolePasswordProvider,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async authenticate(app: AuthRuntime): Promise<AuthResult> {
|
|
32
|
+
const phone = app.config.phone;
|
|
33
|
+
if (!phone) throw new Error("Phone is required for SMS authentication");
|
|
34
|
+
const start = await app.api.auth.requestCode(phone);
|
|
35
|
+
const token = typeof start.token === "string" ? start.token : "";
|
|
36
|
+
if (!token) throw new Error("SMS request returned no token");
|
|
37
|
+
const result = await app.api.auth.sendCode(token, await this.codeProvider(phone));
|
|
38
|
+
if (result.loginToken) return { token: result.loginToken };
|
|
39
|
+
if (result.passwordChallenge) {
|
|
40
|
+
return {
|
|
41
|
+
token: await this.passwordLogin(
|
|
42
|
+
app,
|
|
43
|
+
result.passwordChallenge.trackId,
|
|
44
|
+
result.passwordChallenge.hint,
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (result.registerToken) {
|
|
49
|
+
const registration = app.config.registrationConfig;
|
|
50
|
+
if (!registration) throw new Error("registrationConfig is required for a new account");
|
|
51
|
+
return {
|
|
52
|
+
token: await app.api.auth.confirmRegistration(
|
|
53
|
+
registration.firstName,
|
|
54
|
+
registration.lastName ?? null,
|
|
55
|
+
result.registerToken,
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
throw new Error("Authentication returned no login, registration, or password challenge");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async passwordLogin(
|
|
63
|
+
app: AuthRuntime,
|
|
64
|
+
trackId: string,
|
|
65
|
+
hint: string | null,
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const result = await app.api.auth.checkPassword(trackId, await this.passwordProvider(hint));
|
|
69
|
+
if (result.loginToken) return result.loginToken;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class QrAuthFlow implements AuthFlow {
|
|
75
|
+
constructor(
|
|
76
|
+
readonly qrHandler: QrHandler = consoleQrHandler,
|
|
77
|
+
readonly passwordProvider: PasswordProvider = consolePasswordProvider,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
async authenticate(app: AuthRuntime): Promise<AuthResult> {
|
|
81
|
+
const request = await app.api.auth.requestQr();
|
|
82
|
+
await this.qrHandler(request.qrLink);
|
|
83
|
+
while (Date.now() < request.expiresAt) {
|
|
84
|
+
const status = await app.api.auth.checkQr(request.trackId);
|
|
85
|
+
if (status.loginAvailable) {
|
|
86
|
+
const result = await app.api.auth.confirmQr(request.trackId);
|
|
87
|
+
if (result.loginToken) return { token: result.loginToken };
|
|
88
|
+
if (result.passwordChallenge) {
|
|
89
|
+
for (;;) {
|
|
90
|
+
const checked = await app.api.auth.checkPassword(
|
|
91
|
+
result.passwordChallenge.trackId,
|
|
92
|
+
await this.passwordProvider(result.passwordChallenge.hint),
|
|
93
|
+
);
|
|
94
|
+
if (checked.loginToken) return { token: checked.loginToken };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
await Bun.sleep(request.pollingInterval);
|
|
100
|
+
}
|
|
101
|
+
throw new Error("QR authentication expired");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import QRCode from "qrcode";
|
|
2
|
+
|
|
3
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
4
|
+
export type SmsCodeProvider = (phone: string) => MaybePromise<string>;
|
|
5
|
+
export type PasswordProvider = (hint: string | null) => MaybePromise<string>;
|
|
6
|
+
export type EmailCodeProvider = (email: string) => MaybePromise<string>;
|
|
7
|
+
export type QrHandler = (link: string) => MaybePromise<void>;
|
|
8
|
+
|
|
9
|
+
export const consoleSmsCodeProvider: SmsCodeProvider = (phone) => {
|
|
10
|
+
const code = prompt(`SMS code for ${phone}:`);
|
|
11
|
+
if (!code) throw new Error("SMS code is required");
|
|
12
|
+
return code;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const consolePasswordProvider: PasswordProvider = (hint) => {
|
|
16
|
+
const password = prompt(`2FA password${hint ? ` (${hint})` : ""}:`);
|
|
17
|
+
if (!password) throw new Error("2FA password is required");
|
|
18
|
+
return password;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const consoleEmailCodeProvider: EmailCodeProvider = (email) => {
|
|
22
|
+
const code = prompt(`Email code for ${email}:`);
|
|
23
|
+
if (!code) throw new Error("Email code is required");
|
|
24
|
+
return code;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const consoleQrHandler: QrHandler = async (link) => {
|
|
28
|
+
console.log(await QRCode.toString(link, { type: "terminal", small: true }));
|
|
29
|
+
console.log(link);
|
|
30
|
+
};
|