617 lines
17 KiB
TypeScript
617 lines
17 KiB
TypeScript
import {
|
|
Api as GramJs,
|
|
sessions,
|
|
type Update,
|
|
} from '../../../lib/gramjs';
|
|
import type { TwoFaParams } from '../../../lib/gramjs/client/2fa';
|
|
import TelegramClient from '../../../lib/gramjs/client/TelegramClient';
|
|
import { RPCError } from '../../../lib/gramjs/errors';
|
|
import { Logger as GramJsLogger } from '../../../lib/gramjs/extensions/index';
|
|
|
|
import type { ThreadId } from '../../../types';
|
|
import type {
|
|
ApiInitialArgs,
|
|
ApiMediaFormat,
|
|
ApiOnProgress,
|
|
ApiSessionData,
|
|
} from '../../types';
|
|
|
|
import {
|
|
APP_CODE_NAME,
|
|
DEBUG, DEBUG_GRAMJS, IS_TEST, LANG_PACK, UPLOAD_WORKERS,
|
|
} from '../../../config';
|
|
import { pause } from '../../../util/schedulers';
|
|
import {
|
|
buildApiMessage,
|
|
setMessageBuilderCurrentUserId,
|
|
} from '../apiBuilders/messages';
|
|
import { buildApiPeerId } from '../apiBuilders/peers';
|
|
import { buildApiStory } from '../apiBuilders/stories';
|
|
import { buildApiUser, buildApiUserFullInfo } from '../apiBuilders/users';
|
|
import { buildInputChannelFromLocalDb, buildInputPeerFromLocalDb, getEntityTypeById } from '../gramjsBuilders';
|
|
import {
|
|
addStoryToLocalDb, addUserToLocalDb,
|
|
} from '../helpers/localDb';
|
|
import {
|
|
isResponseUpdate, log,
|
|
} from '../helpers/misc';
|
|
import localDb, { clearLocalDb, type RepairInfo } from '../localDb';
|
|
import { sendApiUpdate } from '../updates/apiUpdateEmitter';
|
|
import { processAndUpdateEntities, processMessageAndUpdateThreadInfo } from '../updates/entityProcessor';
|
|
import {
|
|
getDifference,
|
|
init as initUpdatesManager,
|
|
processUpdate,
|
|
reset as resetUpdatesManager,
|
|
scheduleGetChannelDifference,
|
|
updateChannelState,
|
|
} from '../updates/updateManager';
|
|
import {
|
|
onAuthError, onAuthReady, onCurrentUserUpdate, onRequestCode, onRequestPassword, onRequestPhoneNumber,
|
|
onRequestQrCode, onRequestRegistration, onWebAuthTokenFailed,
|
|
} from './auth';
|
|
import downloadMediaWithClient, { parseMediaUrl } from './media';
|
|
|
|
import { ChatAbortController } from '../ChatAbortController';
|
|
|
|
const DEFAULT_USER_AGENT = 'Unknown UserAgent';
|
|
const DEFAULT_PLATFORM = 'Unknown platform';
|
|
|
|
GramJsLogger.setLevel(DEBUG_GRAMJS ? 'debug' : 'warn');
|
|
|
|
const gramJsUpdateEventBuilder = { build: (update: Update) => update };
|
|
|
|
const CHAT_ABORT_CONTROLLERS = new Map<string, ChatAbortController>();
|
|
const ABORT_CONTROLLERS = new Map<string, AbortController>();
|
|
|
|
let client: TelegramClient;
|
|
let currentUserId: string | undefined;
|
|
|
|
export async function init(initialArgs: ApiInitialArgs, onConnected?: NoneToVoidFunction) {
|
|
if (DEBUG) {
|
|
// eslint-disable-next-line no-console
|
|
console.log('>>> START INIT API');
|
|
}
|
|
|
|
const {
|
|
userAgent, platform, sessionData, isWebmSupported, maxBufferSize, webAuthToken, dcId,
|
|
mockScenario, shouldForceHttpTransport, shouldAllowHttpTransport,
|
|
shouldDebugExportedSenders, langCode, isTestServerRequested, accountIds,
|
|
} = initialArgs;
|
|
|
|
const session = new sessions.CallbackSession(sessionData, onSessionUpdate);
|
|
|
|
(self as any).isWebmSupported = isWebmSupported;
|
|
|
|
(self as any).maxBufferSize = maxBufferSize;
|
|
|
|
client = new TelegramClient(
|
|
session,
|
|
Number(process.env.TELEGRAM_API_ID),
|
|
process.env.TELEGRAM_API_HASH,
|
|
{
|
|
deviceModel: navigator.userAgent || userAgent || DEFAULT_USER_AGENT,
|
|
systemVersion: platform || DEFAULT_PLATFORM,
|
|
appVersion: `${APP_VERSION} ${APP_CODE_NAME}`,
|
|
useWSS: true,
|
|
additionalDcsDisabled: IS_TEST,
|
|
shouldDebugExportedSenders,
|
|
shouldForceHttpTransport,
|
|
shouldAllowHttpTransport,
|
|
dcId,
|
|
langPack: LANG_PACK,
|
|
langCode,
|
|
systemLangCode: navigator.language,
|
|
isTestServerRequested,
|
|
} as any,
|
|
);
|
|
|
|
client.addEventHandler(handleGramJsUpdate, gramJsUpdateEventBuilder);
|
|
|
|
try {
|
|
if (DEBUG) {
|
|
log('CONNECTING');
|
|
|
|
(self as any).invoke = invokeRequest;
|
|
|
|
(self as any).GramJs = GramJs;
|
|
}
|
|
|
|
try {
|
|
client.setPingCallback(getDifference);
|
|
await client.start({
|
|
phoneNumber: onRequestPhoneNumber,
|
|
phoneCode: onRequestCode,
|
|
password: onRequestPassword,
|
|
firstAndLastNames: onRequestRegistration,
|
|
qrCode: onRequestQrCode,
|
|
onError: onAuthError,
|
|
initialMethod: platform === 'iOS' || platform === 'Android' ? 'phoneNumber' : 'qrCode',
|
|
shouldThrowIfUnauthorized: Object.values(sessionData?.keys || {}).length > 0,
|
|
webAuthToken,
|
|
webAuthTokenFailed: onWebAuthTokenFailed,
|
|
mockScenario,
|
|
accountIds,
|
|
}, onConnected);
|
|
} catch (err: any) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
|
|
if (err.message !== 'Disconnect' && err.message !== 'Cannot send requests while disconnected') {
|
|
sendApiUpdate({
|
|
'@type': 'updateConnectionState',
|
|
connectionState: 'connectionStateBroken',
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (DEBUG) {
|
|
// eslint-disable-next-line no-console
|
|
console.log('>>> FINISH INIT API');
|
|
log('CONNECTED');
|
|
}
|
|
|
|
onAuthReady();
|
|
onSessionUpdate(session.getSessionData());
|
|
sendApiUpdate({ '@type': 'updateApiReady' });
|
|
|
|
initUpdatesManager(invokeRequest);
|
|
|
|
void fetchCurrentUser();
|
|
} catch (err) {
|
|
if (DEBUG) {
|
|
log('CONNECTING ERROR', err);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function setIsPremium({ isPremium }: { isPremium: boolean }) {
|
|
client.setIsPremium(isPremium);
|
|
}
|
|
|
|
const LOG_OUT_TIMEOUT = 2500;
|
|
export async function destroy(noLogOut = false, noClearLocalDb = false) {
|
|
if (!noLogOut && client.isConnected()) {
|
|
await Promise.race([
|
|
invokeRequest(new GramJs.auth.LogOut()),
|
|
pause(LOG_OUT_TIMEOUT),
|
|
]);
|
|
}
|
|
|
|
if (!noClearLocalDb) {
|
|
clearLocalDb();
|
|
resetUpdatesManager();
|
|
}
|
|
|
|
await client.destroy();
|
|
}
|
|
|
|
export async function disconnect() {
|
|
await client.disconnect();
|
|
}
|
|
|
|
export function getClient() {
|
|
return client;
|
|
}
|
|
|
|
function onSessionUpdate(sessionData?: ApiSessionData) {
|
|
sendApiUpdate({
|
|
'@type': 'updateSession',
|
|
sessionData,
|
|
});
|
|
}
|
|
|
|
type UpdateConfig = GramJs.UpdateConfig & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] };
|
|
|
|
export function handleGramJsUpdate(update: any) {
|
|
processUpdate(update);
|
|
|
|
if (update instanceof GramJs.UpdatesTooLong) {
|
|
void handleTerminatedSession();
|
|
} else {
|
|
const updates = 'updates' in update ? update.updates : [update];
|
|
updates.forEach((nestedUpdate: any) => {
|
|
if (!(nestedUpdate instanceof GramJs.UpdateConfig)) return;
|
|
|
|
const currentUser = (nestedUpdate as UpdateConfig)._entities
|
|
?.find((entity) => entity instanceof GramJs.User && buildApiPeerId(entity.id, 'user') === currentUserId);
|
|
if (!(currentUser instanceof GramJs.User)) return;
|
|
|
|
setIsPremium({ isPremium: Boolean(currentUser.premium) });
|
|
});
|
|
}
|
|
}
|
|
|
|
type InvokeRequestParams = {
|
|
shouldThrow?: boolean;
|
|
shouldIgnoreUpdates?: boolean;
|
|
dcId?: number;
|
|
shouldIgnoreErrors?: boolean;
|
|
abortControllerChatId?: string;
|
|
abortControllerThreadId?: ThreadId;
|
|
abortControllerGroup?: 'call';
|
|
shouldRetryOnTimeout?: boolean;
|
|
};
|
|
|
|
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
|
request: T,
|
|
params?: InvokeRequestParams & { shouldReturnTrue?: false },
|
|
): Promise<T['__response'] | undefined>;
|
|
|
|
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
|
request: T,
|
|
params?: InvokeRequestParams & { shouldReturnTrue: true },
|
|
): Promise<true | undefined>;
|
|
|
|
export async function invokeRequest<T extends GramJs.AnyRequest>(
|
|
request: T,
|
|
params: InvokeRequestParams & { shouldReturnTrue?: boolean } = {},
|
|
) {
|
|
const {
|
|
shouldThrow, shouldIgnoreUpdates, dcId, shouldIgnoreErrors, abortControllerChatId, abortControllerThreadId,
|
|
shouldRetryOnTimeout, abortControllerGroup,
|
|
} = params;
|
|
const shouldReturnTrue = Boolean(params.shouldReturnTrue);
|
|
|
|
let abortSignal: AbortSignal | undefined;
|
|
if (abortControllerChatId) {
|
|
let controller = CHAT_ABORT_CONTROLLERS.get(abortControllerChatId);
|
|
if (!controller) {
|
|
controller = new ChatAbortController();
|
|
CHAT_ABORT_CONTROLLERS.set(abortControllerChatId, controller);
|
|
}
|
|
|
|
abortSignal = abortControllerThreadId ? controller.getThreadSignal(abortControllerThreadId) : controller.signal;
|
|
}
|
|
|
|
if (abortControllerGroup) {
|
|
let controller = ABORT_CONTROLLERS.get(abortControllerGroup);
|
|
if (!controller) {
|
|
controller = new AbortController();
|
|
ABORT_CONTROLLERS.set(abortControllerGroup, controller);
|
|
}
|
|
abortSignal = controller.signal;
|
|
}
|
|
|
|
try {
|
|
if (DEBUG) {
|
|
log('INVOKE', request.className);
|
|
}
|
|
|
|
const result = await client.invoke(request, dcId, abortSignal, shouldRetryOnTimeout);
|
|
|
|
processAndUpdateEntities(result);
|
|
|
|
if (DEBUG) {
|
|
log('RESPONSE', request.className, result);
|
|
}
|
|
|
|
if (!shouldIgnoreUpdates && isResponseUpdate(result)) {
|
|
processUpdate(result);
|
|
}
|
|
|
|
return shouldReturnTrue ? result && true : result;
|
|
} catch (err: any) {
|
|
if (shouldIgnoreErrors) return undefined;
|
|
if (DEBUG) {
|
|
log('INVOKE ERROR', request.className);
|
|
// eslint-disable-next-line no-console
|
|
console.debug('invokeRequest failed with payload', request);
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
}
|
|
|
|
const message = err instanceof RPCError ? err.errorMessage : err.message;
|
|
|
|
if (message.includes('FROZEN_METHOD_INVALID')) {
|
|
dispatchNotSupportedInFrozenAccountUpdate(err, request);
|
|
}
|
|
|
|
if (shouldThrow) {
|
|
throw err;
|
|
}
|
|
|
|
dispatchErrorUpdate(err, request);
|
|
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function invokeRequestBeacon<T extends GramJs.AnyRequest>(
|
|
request: T,
|
|
dcId?: number,
|
|
) {
|
|
if (DEBUG) {
|
|
log('BEACON', request.className);
|
|
}
|
|
|
|
client.invokeBeacon(request, dcId);
|
|
}
|
|
|
|
export async function downloadMedia(
|
|
args: {
|
|
url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean;
|
|
},
|
|
onProgress?: ApiOnProgress,
|
|
) {
|
|
try {
|
|
const result = await downloadMediaWithClient(args, client, onProgress);
|
|
return result;
|
|
} catch (err: unknown) {
|
|
if (err instanceof RPCError) {
|
|
if (err.errorMessage.startsWith('FILE_REFERENCE')) {
|
|
if (DEBUG) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('Trying to repair file reference', args.url);
|
|
}
|
|
const isFileReferenceRepaired = await repairFileReference({ url: args.url });
|
|
if (isFileReferenceRepaired) {
|
|
return downloadMediaWithClient(args, client, onProgress);
|
|
}
|
|
|
|
if (DEBUG) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Failed to repair file reference', args.url);
|
|
}
|
|
}
|
|
|
|
if (err.errorMessage === 'FILE_ID_INVALID' && args.url.includes('avatar')) {
|
|
if (DEBUG) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('Inaccessible avatar image', args.url);
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
if (DEBUG) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Failed to download media', args.url, err);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function uploadFile(file: File, onProgress?: ApiOnProgress) {
|
|
return client.uploadFile({ file, onProgress, workers: UPLOAD_WORKERS });
|
|
}
|
|
|
|
export function updateTwoFaSettings(params: TwoFaParams) {
|
|
return client.updateTwoFaSettings(params);
|
|
}
|
|
|
|
export function getTmpPassword(currentPassword: string, ttl?: number) {
|
|
return client.getTmpPassword(currentPassword, ttl);
|
|
}
|
|
|
|
export function getCurrentPassword(currentPassword?: string) {
|
|
return client.getCurrentPassword(currentPassword);
|
|
}
|
|
|
|
export function abortChatRequests(params: { chatId: string; threadId?: ThreadId }) {
|
|
const { chatId, threadId } = params;
|
|
const controller = CHAT_ABORT_CONTROLLERS.get(chatId);
|
|
if (!threadId) {
|
|
controller?.abort('Chat change');
|
|
CHAT_ABORT_CONTROLLERS.delete(chatId);
|
|
return;
|
|
}
|
|
|
|
controller?.abortThread(threadId, 'Thread change');
|
|
}
|
|
|
|
export function abortRequestGroup(group: string) {
|
|
ABORT_CONTROLLERS.get(group)?.abort();
|
|
ABORT_CONTROLLERS.delete(group);
|
|
}
|
|
|
|
export async function fetchCurrentUser() {
|
|
const userFull = await invokeRequest(new GramJs.users.GetFullUser({
|
|
id: new GramJs.InputUserSelf(),
|
|
}));
|
|
|
|
if (!userFull || !(userFull.users[0] instanceof GramJs.User)) {
|
|
return;
|
|
}
|
|
|
|
const user = userFull.users[0];
|
|
|
|
addUserToLocalDb(user);
|
|
const currentUserFullInfo = buildApiUserFullInfo(userFull);
|
|
const currentUser = buildApiUser(user)!;
|
|
|
|
setMessageBuilderCurrentUserId(currentUser.id);
|
|
onCurrentUserUpdate(currentUser, currentUserFullInfo);
|
|
|
|
currentUserId = currentUser.id;
|
|
setIsPremium({ isPremium: Boolean(currentUser.isPremium) });
|
|
}
|
|
|
|
export function dispatchErrorUpdate<T extends GramJs.AnyRequest>(err: Error, request: T) {
|
|
const message = err instanceof RPCError ? err.errorMessage : err.message;
|
|
const isSlowMode = message === 'FLOOD' && (
|
|
request instanceof GramJs.messages.SendMessage
|
|
|| request instanceof GramJs.messages.SendMedia
|
|
|| request instanceof GramJs.messages.SendMultiMedia
|
|
);
|
|
|
|
sendApiUpdate({
|
|
'@type': 'error',
|
|
error: {
|
|
message,
|
|
isSlowMode,
|
|
hasErrorKey: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
function dispatchNotSupportedInFrozenAccountUpdate<T extends GramJs.AnyRequest>(err: Error, request: T) {
|
|
if (!(err instanceof RPCError)) return;
|
|
const message = err.errorMessage;
|
|
|
|
if (
|
|
request instanceof GramJs.messages.GetPinnedDialogs
|
|
|| request instanceof GramJs.phone.GetGroupParticipants
|
|
|| request instanceof GramJs.channels.GetParticipant
|
|
|| request instanceof GramJs.channels.GetParticipants
|
|
|| request instanceof GramJs.channels.GetForumTopics) {
|
|
return;
|
|
}
|
|
|
|
sendApiUpdate({
|
|
'@type': 'notSupportedInFrozenAccount',
|
|
error: {
|
|
message,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleTerminatedSession() {
|
|
try {
|
|
await invokeRequest(new GramJs.users.GetFullUser({
|
|
id: new GramJs.InputUserSelf(),
|
|
}), {
|
|
shouldThrow: true,
|
|
});
|
|
} catch (err: any) {
|
|
if (
|
|
err.errorMessage === 'AUTH_KEY_UNREGISTERED'
|
|
|| err.errorMessage === 'SESSION_REVOKED'
|
|
|| err.errorMessage === 'USER_DEACTIVATED'
|
|
) {
|
|
sendApiUpdate({
|
|
'@type': 'updateConnectionState',
|
|
connectionState: 'connectionStateBroken',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function repairFileReference({
|
|
url,
|
|
}: {
|
|
url: string;
|
|
}) {
|
|
const parsed = parseMediaUrl(url);
|
|
if (!parsed) return undefined;
|
|
|
|
const {
|
|
entityId, mediaMatchType,
|
|
} = parsed;
|
|
|
|
if (mediaMatchType === 'document' || mediaMatchType === 'photo' || mediaMatchType === 'webDocument') {
|
|
const entity = mediaMatchType === 'document'
|
|
? localDb.documents[entityId] : mediaMatchType === 'webDocument'
|
|
? localDb.webDocuments[entityId] : localDb.photos[entityId];
|
|
if (!entity) return false;
|
|
const repairableEntity = entity as RepairInfo;
|
|
if (!repairableEntity.localRepairInfo) return false;
|
|
const { localRepairInfo } = repairableEntity;
|
|
|
|
if (localRepairInfo.type === 'story') {
|
|
const result = await repairStoryMedia(localRepairInfo.peerId, localRepairInfo.id);
|
|
return result;
|
|
}
|
|
|
|
if (localRepairInfo.type === 'message') {
|
|
const result = await repairMessageMedia(localRepairInfo.peerId, localRepairInfo.id);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function repairMessageMedia(peerId: string, messageId: number) {
|
|
const type = getEntityTypeById(peerId);
|
|
const inputChannel = buildInputChannelFromLocalDb(peerId);
|
|
let result;
|
|
|
|
if (type === 'channel' && inputChannel) {
|
|
result = await invokeRequest(new GramJs.channels.GetMessages({
|
|
channel: inputChannel,
|
|
id: [new GramJs.InputMessageID({ id: messageId })],
|
|
}), {
|
|
shouldIgnoreErrors: true,
|
|
});
|
|
} else {
|
|
result = await invokeRequest(
|
|
new GramJs.messages.GetMessages({
|
|
id: [new GramJs.InputMessageID({ id: messageId })],
|
|
}),
|
|
{
|
|
shouldIgnoreErrors: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
if (!result || result instanceof GramJs.messages.MessagesNotModified) return false;
|
|
|
|
if (inputChannel && 'pts' in result) {
|
|
updateChannelState(peerId, result.pts);
|
|
}
|
|
|
|
const message = result.messages[0];
|
|
if (message instanceof GramJs.MessageEmpty) return false;
|
|
|
|
processMessageAndUpdateThreadInfo(message);
|
|
|
|
const apiMessage = buildApiMessage(message);
|
|
if (apiMessage) {
|
|
sendApiUpdate({
|
|
'@type': 'updateMessage',
|
|
chatId: apiMessage.chatId,
|
|
id: apiMessage.id,
|
|
message: apiMessage,
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function repairStoryMedia(peerId: string, storyId: number) {
|
|
const peer = buildInputPeerFromLocalDb(peerId);
|
|
if (!peer) return false;
|
|
|
|
const result = await invokeRequest(new GramJs.stories.GetStoriesByID({
|
|
peer,
|
|
id: [storyId],
|
|
}), {
|
|
shouldIgnoreErrors: true,
|
|
});
|
|
if (!result) return false;
|
|
|
|
result.stories.forEach((story) => {
|
|
const apiStory = buildApiStory(peerId, story);
|
|
if (!apiStory || 'isDeleted' in apiStory) return;
|
|
|
|
addStoryToLocalDb(story, peerId);
|
|
sendApiUpdate({
|
|
'@type': 'updateStory',
|
|
peerId,
|
|
story: apiStory,
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
|
|
export function setForceHttpTransport(forceHttpTransport: boolean) {
|
|
client.setForceHttpTransport(forceHttpTransport);
|
|
}
|
|
|
|
export function setAllowHttpTransport(allowHttpTransport: boolean) {
|
|
client.setAllowHttpTransport(allowHttpTransport);
|
|
}
|
|
|
|
export function setShouldDebugExportedSenders(value: boolean) {
|
|
client.setShouldDebugExportedSenders(value);
|
|
}
|
|
|
|
export function requestChannelDifference(channelId: string) {
|
|
scheduleGetChannelDifference(channelId);
|
|
}
|