UNPKG

dograma

Version:

NodeJS/Browser MTProto API Telegram client library,

862 lines (793 loc) 25.9 kB
import { Api } from "../tl"; import type { TelegramClient } from "./TelegramClient"; import { strippedPhotoToJpg } from "../Utils"; import { sleep } from "../Helpers"; import { EntityLike, OutFile, ProgressCallback } from "../define"; import { utils } from "../"; import { RequestIter } from "../requestIter"; import { MTProtoSender } from "../network"; import { FileMigrateError } from "../errors"; import { createWriteStream } from "./fs"; import { BinaryWriter } from "../extensions"; import * as fs from "./fs"; import path from "./path"; import bigInt from "big-integer"; /** * progress callback that will be called each time a new chunk is downloaded. */ export interface progressCallback { ( /** How much was downloaded */ downloaded: bigInt.BigInteger, /** Full size of the file to be downloaded */ fullSize: bigInt.BigInteger, /** other args to be passed if needed */ ...args: any[] ): void; /** When this value is set to true the download will stop */ isCanceled?: boolean; /** Does nothing for now. */ acceptsBuffer?: boolean; } /** * Low level interface for downloading files */ export interface DownloadFileParams { /** The dcId that the file belongs to. Used to borrow a sender from that DC */ dcId: number; /** How much to download. The library will download until it reaches this amount.<br/> * can be useful for downloading by chunks */ fileSize?: number; /** Used to determine how many download tasks should be run in parallel. anything above 16 is unstable. */ workers?: number; /** How much to download in each chunk. The larger the less requests to be made. (max is 512kb). */ partSizeKb?: number; /** Where to start downloading. useful for chunk downloading. */ start?: number; /** Where to stop downloading. useful for chunk downloading. */ end?: number; /** A callback function accepting two parameters: ``(received bytes, total)``. */ progressCallback?: progressCallback; } /** * Low level interface for downloading files */ export interface DownloadFileParamsV2 { /** * The output file path, directory,buffer, or stream-like object. * If the path exists and is a file, it will be overwritten. * If the file path is `undefined` or `Buffer`, then the result will be saved in memory and returned as `Buffer`. */ outputFile?: OutFile; /** The dcId that the file belongs to. Used to borrow a sender from that DC. The library should handle this for you */ dcId?: number; /** The file size that is about to be downloaded, if known.<br/> Only used if ``progressCallback`` is specified. */ fileSize?: bigInt.BigInteger; /** How much to download in each chunk. The larger the less requests to be made. (max is 512kb). */ partSizeKb?: number; /** Progress callback accepting one param. (progress :number) which is a float between 0 and 1 */ progressCallback?: progressCallback; /** */ msgData?: [EntityLike, number]; } /** * contains optional download params for profile photo. */ export interface DownloadProfilePhotoParams { /** Whether to download the big version or the small one of the photo */ isBig?: boolean; outputFile?: OutFile; } interface Deferred { promise: Promise<any>; resolve: (value?: any) => void; } // All types const sizeTypes = ["w", "y", "d", "x", "c", "m", "b", "a", "s"]; // Chunk sizes for `upload.getFile` must be multiple of the smallest size const MIN_CHUNK_SIZE = 4096; const DEFAULT_CHUNK_SIZE = 64; // kb const ONE_MB = 1024 * 1024; const REQUEST_TIMEOUT = 15000; const DISCONNECT_SLEEP = 1000; const TIMED_OUT_SLEEP = 1000; const MAX_CHUNK_SIZE = 512 * 1024; export interface DirectDownloadIterInterface { fileLocation: Api.TypeInputFileLocation; dcId: number; offset: bigInt.BigInteger; stride: number; chunkSize: number; requestSize: number; fileSize: number; msgData: number; } export interface IterDownloadFunction { file?: Api.TypeMessageMedia | Api.TypeInputFile | Api.TypeInputFileLocation; offset?: bigInt.BigInteger; stride?: number; limit?: number; chunkSize?: number; requestSize: number; fileSize?: bigInt.BigInteger; dcId?: number; msgData?: [EntityLike, number]; } export class DirectDownloadIter extends RequestIter { protected request?: Api.upload.GetFile; private _sender?: MTProtoSender; private _timedOut: boolean = false; protected _stride?: number; protected _chunkSize?: number; protected _lastPart?: Buffer; protected buffer: Buffer[] | undefined; async _init({ fileLocation, dcId, offset, stride, chunkSize, requestSize, fileSize, msgData, }: DirectDownloadIterInterface) { this.request = new Api.upload.GetFile({ location: fileLocation, offset, limit: requestSize, }); this.total = fileSize; this._stride = stride; this._chunkSize = chunkSize; this._lastPart = undefined; //this._msgData = msgData; this._timedOut = false; this._sender = await this.client.getSender(dcId); } async _loadNextChunk(): Promise<boolean | undefined> { const current = await this._request(); this.buffer!.push(current); if (current.length < this.request!.limit) { // we finished downloading this.left = this.buffer!.length; await this.close(); return true; } else { this.request!.offset = this.request!.offset.add(this._stride!); } } async _request(): Promise<Buffer> { try { this._sender = await this.client.getSender(this._sender!.dcId); const result = await this.client.invoke( this.request!, this._sender ); this._timedOut = false; if (result instanceof Api.upload.FileCdnRedirect) { throw new Error( "CDN Not supported. Please Add an issue in github" ); } return result.bytes; } catch (e: any) { if (e.errorMessage == "TIMEOUT") { if (this._timedOut) { this.client._log.warn( "Got two timeouts in a row while downloading file" ); throw e; } this._timedOut = true; this.client._log.info( "Got timeout while downloading file, retrying once" ); await sleep(TIMED_OUT_SLEEP); return await this._request(); } else if (e instanceof FileMigrateError) { this.client._log.info("File lives in another DC"); this._sender = await this.client.getSender(e.newDc); return await this._request(); } else if (e.errorMessage == "FILEREF_UPGRADE_NEEDED") { // TODO later throw e; } else { throw e; } } } async close() { this.client._log.debug("Finished downloading file ..."); } [Symbol.asyncIterator](): AsyncIterator<Buffer, any, undefined> { return super[Symbol.asyncIterator](); } } export class GenericDownloadIter extends DirectDownloadIter { async _loadNextChunk(): Promise<boolean | undefined> { // 1. Fetch enough for one chunk let data = Buffer.alloc(0); // 1.1. ``bad`` is how much into the data we have we need to offset const bad = this.request!.offset.divide( this.request!.limit ).toJSNumber(); const before = this.request!.offset; // 1.2. We have to fetch from a valid offset, so remove that bad part this.request!.offset = this.request!.offset.subtract(bad); let done = false; while (!done && data.length - bad < this._chunkSize!) { const current = await this._request(); this.request!.offset = this.request!.offset.add( this.request!.limit ); data = Buffer.concat([data, current]); done = current.length < this.request!.limit; } // 1.3 Restore our last desired offset this.request!.offset = before; // 2. Fill the buffer with the data we have // 2.1. The current chunk starts at ``bad`` offset into the data, // and each new chunk is ``stride`` bytes apart of the other for (let i = bad; i < data.length; i += this._stride!) { this.buffer!.push(data.slice(i, i + this._chunkSize!)); // 2.2. We will yield this offset, so move to the next one this.request!.offset = this.request!.offset.add(this._stride!); } // 2.3. If we are in the last chunk, we will return the last partial data if (done) { this.left = this.buffer!.length; await this.close(); return; } // 2.4 If we are not done, we can't return incomplete chunks. if (this.buffer![this.buffer!.length - 1].length != this._chunkSize) { this._lastPart = this.buffer!.pop(); // 3. Be careful with the offsets. Re-fetching a bit of data // is fine, since it greatly simplifies things. // TODO Try to not re-fetch data this.request!.offset = this.request!.offset.subtract(this._stride!); } } } /** @hidden */ export function iterDownload( client: TelegramClient, { file, offset = bigInt.zero, stride, limit, chunkSize, requestSize = MAX_CHUNK_SIZE, fileSize, dcId, msgData, }: IterDownloadFunction ) { // we're ignoring here to make it more flexible (which is probably a bad idea) // @ts-ignore const info = utils.getFileInfo(file); if (info.dcId != undefined) { dcId = info.dcId; } if (fileSize == undefined) { fileSize = info.size; } file = info.location; if (chunkSize == undefined) { chunkSize = requestSize; } if (limit == undefined && fileSize != undefined) { limit = Math.floor( fileSize.add(chunkSize).subtract(1).divide(chunkSize).toJSNumber() ); } if (stride == undefined) { stride = chunkSize; } else if (stride < chunkSize) { throw new Error("Stride must be >= chunkSize"); } requestSize -= requestSize % MIN_CHUNK_SIZE; if (requestSize < MIN_CHUNK_SIZE) { requestSize = MIN_CHUNK_SIZE; } else if (requestSize > MAX_CHUNK_SIZE) { requestSize = MAX_CHUNK_SIZE; } let cls; if ( chunkSize == requestSize && offset!.divide(MAX_CHUNK_SIZE).eq(bigInt.zero) && stride % MIN_CHUNK_SIZE == 0 && (limit == undefined || offset!.divide(limit).eq(bigInt.zero)) ) { cls = DirectDownloadIter; client._log.info( `Starting direct file download in chunks of ${requestSize} at ${offset}, stride ${stride}` ); } else { cls = GenericDownloadIter; client._log.info( `Starting indirect file download in chunks of ${requestSize} at ${offset}, stride ${stride}` ); } return new cls( client, limit, {}, { fileLocation: file, dcId, offset, stride, chunkSize, requestSize, fileSize, msgData, } ); } function getWriter(outputFile?: OutFile) { if (!outputFile || Buffer.isBuffer(outputFile)) { return new BinaryWriter(Buffer.alloc(0)); } else if (typeof outputFile == "string") { // We want to make sure that the path exists. return createWriteStream(outputFile); } else { return outputFile; } } function closeWriter( writer: BinaryWriter | { write: Function; close?: Function } ) { if ("close" in writer && writer.close) { writer.close(); } } function returnWriterValue(writer: any): Buffer | string | undefined { if (writer instanceof BinaryWriter) { return writer.getValue(); } if (writer instanceof fs.WriteStream) { if (typeof writer.path == "string") { return path.resolve(writer.path); } else { return Buffer.from(writer.path); } } } /** @hidden */ export async function downloadFileV2( client: TelegramClient, inputLocation: Api.TypeInputFileLocation, { outputFile = undefined, partSizeKb = undefined, fileSize = undefined, progressCallback = undefined, dcId = undefined, msgData = undefined, }: DownloadFileParamsV2 ) { if (!partSizeKb) { if (!fileSize) { partSizeKb = 64; } else { partSizeKb = utils.getAppropriatedPartSize(fileSize); } } const partSize = Math.floor(partSizeKb * 1024); if (partSize % MIN_CHUNK_SIZE != 0) { throw new Error("The part size must be evenly divisible by 4096"); } const writer = getWriter(outputFile); let downloaded = bigInt.zero; try { for await (const chunk of iterDownload(client, { file: inputLocation, requestSize: partSize, dcId: dcId, msgData: msgData, })) { await writer.write(chunk); if (progressCallback) { await progressCallback( downloaded, bigInt(fileSize || bigInt.zero) ); } downloaded = downloaded.add(chunk.length); } return returnWriterValue(writer); } finally { closeWriter(writer); } } class Foreman { private deferred: Deferred | undefined; private activeWorkers = 0; constructor(private maxWorkers: number) {} requestWorker() { this.activeWorkers++; if (this.activeWorkers > this.maxWorkers) { this.deferred = createDeferred(); return this.deferred.promise; } return Promise.resolve(); } releaseWorker() { this.activeWorkers--; if (this.deferred && this.activeWorkers <= this.maxWorkers) { this.deferred.resolve(); } } } function createDeferred(): Deferred { let resolve: Deferred["resolve"]; const promise = new Promise((_resolve) => { resolve = _resolve; }); return { promise, resolve: resolve!, }; } /** * All of these are optional and will be calculated automatically if not specified. */ export interface DownloadMediaInterface { /** * The output file location, if left undefined this method will return a buffer */ outputFile?: OutFile; /** * Which thumbnail size from the document or photo to download, instead of downloading the document or photo itself.<br/> <br/> If it's specified but the file does not have a thumbnail, this method will return `undefined`.<br/> <br/> The parameter should be an integer index between ``0`` and ``sizes.length``.<br/> ``0`` will download the smallest thumbnail, and ``sizes.length - 1`` will download the largest thumbnail.<br/> <br/> You can also pass the `Api.PhotoSize` instance to use. Alternatively, the thumb size type `string` may be used.<br/> <br/> In short, use ``thumb=0`` if you want the smallest thumbnail and ``thumb=sizes.length`` if you want the largest thumbnail. */ thumb?: number | Api.TypePhotoSize; /** * A callback function accepting two parameters: * ``(received bytes, total)``. */ progressCallback?: ProgressCallback; } /** @hidden */ export async function downloadMedia( client: TelegramClient, messageOrMedia: Api.Message | Api.TypeMessageMedia, outputFile?: OutFile, thumb?: number | Api.TypePhotoSize, progressCallback?: ProgressCallback ): Promise<Buffer | string | undefined> { /* Downloading large documents may be slow enough to require a new file reference to be obtained mid-download. Store (input chat, message id) so that the message can be re-fetched. */ let msgData: [EntityLike, number] | undefined; let date; let media; if (messageOrMedia instanceof Api.Message) { media = messageOrMedia.media; date = messageOrMedia.date; msgData = messageOrMedia.inputChat ? [messageOrMedia.inputChat, messageOrMedia.id] : undefined; } else { media = messageOrMedia; date = Date.now(); } if (typeof media == "string") { throw new Error("not implemented"); } if (media instanceof Api.MessageMediaWebPage) { if (media.webpage instanceof Api.WebPage) { media = media.webpage.document || media.webpage.photo; } } if (media instanceof Api.MessageMediaPhoto || media instanceof Api.Photo) { return _downloadPhoto( client, media, outputFile, date, thumb, progressCallback ); } else if ( media instanceof Api.MessageMediaDocument || media instanceof Api.Document ) { return _downloadDocument( client, media, outputFile, date, thumb, progressCallback, msgData ); } else if (media instanceof Api.MessageMediaContact) { return _downloadContact(client, media, {}); } else if ( media instanceof Api.WebDocument || media instanceof Api.WebDocumentNoProxy ) { return _downloadWebDocument(client, media, {}); } else { return Buffer.alloc(0); } } /** @hidden */ export async function _downloadDocument( client: TelegramClient, doc: Api.MessageMediaDocument | Api.TypeDocument, outputFile: OutFile | undefined, date: number, thumb?: number | string | Api.TypePhotoSize, progressCallback?: ProgressCallback, msgData?: [EntityLike, number] ): Promise<Buffer | string | undefined> { if (doc instanceof Api.MessageMediaDocument) { if (!doc.document) { return Buffer.alloc(0); } doc = doc.document; } if (!(doc instanceof Api.Document)) { return Buffer.alloc(0); } let size; if (thumb == undefined) { outputFile = getProperFilename( outputFile, "document", "." + (utils.getExtension(doc) || "bin"), date ); } else { outputFile = getProperFilename(outputFile, "photo", ".jpg", date); size = getThumb(doc.thumbs || [], thumb); if ( size instanceof Api.PhotoCachedSize || size instanceof Api.PhotoStrippedSize ) { return _downloadCachedPhotoSize(size, outputFile); } } return await downloadFileV2( client, new Api.InputDocumentFileLocation({ id: doc.id, accessHash: doc.accessHash, fileReference: doc.fileReference, thumbSize: size ? size.type : "", }), { outputFile: outputFile, fileSize: size && "size" in size ? bigInt(size.size) : doc.size, progressCallback: progressCallback, msgData: msgData, } ); } /** @hidden */ export async function _downloadContact( client: TelegramClient, media: Api.MessageMediaContact, args: DownloadMediaInterface ): Promise<Buffer> { throw new Error("not implemented"); } /** @hidden */ export async function _downloadWebDocument( client: TelegramClient, media: Api.WebDocument | Api.WebDocumentNoProxy, args: DownloadMediaInterface ): Promise<Buffer> { throw new Error("not implemented"); } function pickFileSize(sizes: Api.TypePhotoSize[], sizeType: string) { if (!sizeType || !sizes || !sizes.length) { return undefined; } const indexOfSize = sizeTypes.indexOf(sizeType); let size; for (let i = indexOfSize; i < sizeTypes.length; i++) { size = sizes.find((s) => s.type === sizeTypes[i]); if (size && !(size instanceof Api.PhotoPathSize)) { return size; } } return undefined; } /** @hidden */ function getThumb( thumbs: (Api.TypePhotoSize | Api.VideoSize)[], thumb?: number | string | Api.TypePhotoSize | Api.VideoSize ) { function sortThumb(thumb: Api.TypePhotoSize | Api.VideoSize) { if (thumb instanceof Api.PhotoStrippedSize) { return thumb.bytes.length; } if (thumb instanceof Api.PhotoCachedSize) { return thumb.bytes.length; } if (thumb instanceof Api.PhotoSize) { return thumb.size; } if (thumb instanceof Api.PhotoSizeProgressive) { return Math.max(...thumb.sizes); } if (thumb instanceof Api.VideoSize) { return thumb.size; } return 0; } thumbs = thumbs.sort((a, b) => sortThumb(a) - sortThumb(b)); const correctThumbs = []; for (const t of thumbs) { if (!(t instanceof Api.PhotoPathSize)) { correctThumbs.push(t); } } if (thumb == undefined) { return correctThumbs.pop(); } else if (typeof thumb == "number") { return correctThumbs[thumb]; } else if (typeof thumb == "string") { for (const t of correctThumbs) { if (t.type == thumb) { return t; } } } else if ( thumb instanceof Api.PhotoSize || thumb instanceof Api.PhotoCachedSize || thumb instanceof Api.PhotoStrippedSize || thumb instanceof Api.VideoSize ) { return thumb; } } /** @hidden */ export async function _downloadCachedPhotoSize( size: Api.PhotoCachedSize | Api.PhotoStrippedSize, outputFile?: OutFile ) { // No need to download anything, simply write the bytes let data: Buffer; if (size instanceof Api.PhotoStrippedSize) { data = strippedPhotoToJpg(size.bytes); } else { data = size.bytes; } const writer = getWriter(outputFile); try { await writer.write(data); } finally { closeWriter(writer); } return returnWriterValue(writer); } /** @hidden */ function getProperFilename( file: OutFile | undefined, fileType: string, extension: string, date: number ) { if (!file || typeof file != "string") { return file; } if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) { let fullName = fileType + date + extension; return path.join(file, fullName); } return file; } /** @hidden */ export async function _downloadPhoto( client: TelegramClient, photo: Api.MessageMediaPhoto | Api.Photo, file?: OutFile, date?: number, thumb?: number | string | Api.TypePhotoSize, progressCallback?: progressCallback ): Promise<Buffer | string | undefined> { if (photo instanceof Api.MessageMediaPhoto) { if (photo.photo instanceof Api.PhotoEmpty || !photo.photo) { return Buffer.alloc(0); } photo = photo.photo; } if (!(photo instanceof Api.Photo)) { return Buffer.alloc(0); } const photoSizes = [...(photo.sizes || []), ...(photo.videoSizes || [])]; const size = getThumb(photoSizes, thumb); if (!size || size instanceof Api.PhotoSizeEmpty) { return Buffer.alloc(0); } if (!date) { date = Date.now(); } file = getProperFilename(file, "photo", ".jpg", date); if ( size instanceof Api.PhotoCachedSize || size instanceof Api.PhotoStrippedSize ) { return _downloadCachedPhotoSize(size, file); } let fileSize: number; if (size instanceof Api.PhotoSizeProgressive) { fileSize = Math.max(...size.sizes); } else { fileSize = size.size; } return downloadFileV2( client, new Api.InputPhotoFileLocation({ id: photo.id, accessHash: photo.accessHash, fileReference: photo.fileReference, thumbSize: size.type, }), { outputFile: file, fileSize: bigInt(fileSize), progressCallback: progressCallback, dcId: photo.dcId, } ); } /** @hidden */ export async function downloadProfilePhoto( client: TelegramClient, entity: EntityLike, fileParams: DownloadProfilePhotoParams ) { let photo; if (typeof entity == "object" && "photo" in entity) { photo = entity.photo; } else { entity = await client.getEntity(entity); if ("photo" in entity) { photo = entity.photo; } else { throw new Error( `Could not get photo from ${ entity ? entity.className : undefined }` ); } } let dcId; let loc; if ( photo instanceof Api.UserProfilePhoto || photo instanceof Api.ChatPhoto ) { dcId = photo.dcId; loc = new Api.InputPeerPhotoFileLocation({ peer: utils.getInputPeer(entity), photoId: photo.photoId, big: fileParams.isBig, }); } else { return Buffer.alloc(0); } return client.downloadFile(loc, { outputFile: fileParams.outputFile, dcId, }); }