UNPKG

dograma

Version:

NodeJS/Browser MTProto API Telegram client library,

717 lines (677 loc) 23.9 kB
import { Api } from "../tl"; import { TelegramClient } from "./TelegramClient"; import { generateRandomBytes, readBigIntFromBuffer, sleep } from "../Helpers"; import { getAppropriatedPartSize, getInputMedia } from "../Utils"; import { EntityLike, FileLike, MarkupLike, MessageIDLike } from "../define"; import path from "./path"; import { promises as fs } from "./fs"; import { errors, utils } from "../index"; import { _parseMessageText } from "./messageParse"; import { getCommentData } from "./messages"; import bigInt from "big-integer"; interface OnProgress { // Float between 0 and 1. (progress: number): void; isCanceled?: boolean; } /** * interface for uploading files. */ export interface UploadFileParams { /** for browsers this should be an instance of File.<br/> * On node you should use {@link CustomFile} class to wrap your file. */ file: File | CustomFile; /** How many workers to use to upload the file. anything above 16 is unstable. */ workers: number; /** a progress callback for the upload. */ onProgress?: OnProgress; } /** * A custom file class that mimics the browser's File class.<br/> * You should use this whenever you want to upload a file. */ export class CustomFile { /** The name of the file to be uploaded. This is what will be shown in telegram */ name: string; /** The size of the file. this should be the exact size to not lose any information */ size: number; /** The full path on the system to where the file is. this will be used to read the file from.<br/> * Can be left empty to use a buffer instead */ path: string; /** in case of the no path a buffer can instead be passed instead to upload. */ buffer?: Buffer; constructor(name: string, size: number, path: string, buffer?: Buffer) { this.name = name; this.size = size; this.path = path; this.buffer = buffer; } } interface CustomBufferOptions { filePath?: string; buffer?: Buffer; } class CustomBuffer { constructor(private readonly options: CustomBufferOptions) { if (!options.buffer && !options.filePath) { throw new Error( "Either one of `buffer` or `filePath` should be specified" ); } } async slice(begin: number, end: number): Promise<Buffer> { const { buffer, filePath } = this.options; if (buffer) { return buffer.slice(begin, end); } else if (filePath) { const buffSize = end - begin; const buff = Buffer.alloc(buffSize); const fHandle = await fs.open(filePath, "r"); await fHandle.read(buff, 0, buffSize, begin); await fHandle.close(); return Buffer.from(buff); } return Buffer.alloc(0); } } const KB_TO_BYTES = 1024; const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; const UPLOAD_TIMEOUT = 15 * 1000; const DISCONNECT_SLEEP = 1000; async function getFileBuffer( file: File | CustomFile, fileSize: number ): Promise<CustomBuffer> { const isBiggerThan2Gb = fileSize > 2 ** 31 - 1; const options: CustomBufferOptions = {}; if (isBiggerThan2Gb && file instanceof CustomFile) { options.filePath = file.path; } else { options.buffer = Buffer.from(await fileToBuffer(file)); } return new CustomBuffer(options); } /** @hidden */ export async function uploadFile( client: TelegramClient, fileParams: UploadFileParams ): Promise<Api.InputFile | Api.InputFileBig> { const { file, onProgress } = fileParams; let { workers } = fileParams; const { name, size } = file; const fileId = readBigIntFromBuffer(generateRandomBytes(8), true, true); const isLarge = size > LARGE_FILE_THRESHOLD; const partSize = getAppropriatedPartSize(bigInt(size)) * KB_TO_BYTES; const partCount = Math.floor((size + partSize - 1) / partSize); const buffer = await getFileBuffer(file, size); // Make sure a new sender can be created before starting upload await client.getSender(client.session.dcId); if (!workers || !size) { workers = 1; } if (workers >= partCount) { workers = partCount; } let progress = 0; if (onProgress) { onProgress(progress); } for (let i = 0; i < partCount; i += workers) { const sendingParts = []; let end = i + workers; if (end > partCount) { end = partCount; } for (let j = i; j < end; j++) { const bytes = await buffer.slice(j * partSize, (j + 1) * partSize); // eslint-disable-next-line no-loop-func sendingParts.push( (async (jMemo: number, bytesMemo: Buffer) => { while (true) { let sender; try { // We always upload from the DC we are in sender = await client.getSender( client.session.dcId ); await sender.send( isLarge ? new Api.upload.SaveBigFilePart({ fileId, filePart: jMemo, fileTotalParts: partCount, bytes: bytesMemo, }) : new Api.upload.SaveFilePart({ fileId, filePart: jMemo, bytes: bytesMemo, }) ); } catch (err: any) { if (sender && !sender.isConnected()) { await sleep(DISCONNECT_SLEEP); continue; } else if (err instanceof errors.FloodWaitError) { await sleep(err.seconds * 1000); continue; } throw err; } if (onProgress) { if (onProgress.isCanceled) { throw new Error("USER_CANCELED"); } progress += 1 / partCount; onProgress(progress); } break; } })(j, bytes) ); } await Promise.all(sendingParts); } return isLarge ? new Api.InputFileBig({ id: fileId, parts: partCount, name, }) : new Api.InputFile({ id: fileId, parts: partCount, name, md5Checksum: "", // This is not a "flag", so not sure if we can make it optional. }); } /** * Interface for sending files to a chat. */ export interface SendFileInterface { /** a file like object. * - can be a localpath. the file name will be used. * - can be a Buffer with a ".name" attribute to use as the file name. * - can be an external direct URL. Telegram will download the file and send it. * - can be an existing media from another message. * - can be a handle to a file that was received by using {@link uploadFile} * - can be a list when using an album * - can be {@link Api.TypeInputMedia} instance. For example if you want to send a dice you would use {@link Api.InputMediaDice} */ file: FileLike | FileLike[]; /** Optional caption for the sent media message. can be a list for albums*/ caption?: string | string[]; /** If left to false and the file is a path that ends with the extension of an image file or a video file, it will be sent as such. Otherwise always as a document. */ forceDocument?: boolean; /** The size of the file to be uploaded if it needs to be uploaded, which will be determined automatically if not specified. */ fileSize?: number; /** Whether the existing draft should be cleared or not. */ clearDraft?: boolean; /** progress callback that will be called each time a new chunk is downloaded. */ progressCallback?: OnProgress; /** Same as `replyTo` from {@link sendMessage}. */ replyTo?: MessageIDLike; /** Optional attributes that override the inferred ones, like {@link Api.DocumentAttributeFilename} and so on.*/ attributes?: Api.TypeDocumentAttribute[]; /** Optional JPEG thumbnail (for documents). Telegram will ignore this parameter unless you pass a .jpg file!<br/> * The file must also be small in dimensions and in disk size. Successful thumbnails were files below 20kB and 320x320px.<br/> * Width/height and dimensions/size ratios may be important. * For Telegram to accept a thumbnail, you must provide the dimensions of the underlying media through `attributes:` with DocumentAttributesVideo. */ thumb?: FileLike; /** If true the audio will be sent as a voice note. */ voiceNote?: boolean; /** If true the video will be sent as a video note, also known as a round video message.*/ videoNote?: boolean; /** Whether the sent video supports streaming or not.<br/> * Note that Telegram only recognizes as streamable some formats like MP4, and others like AVI or MKV will not work.<br/> * You should convert these to MP4 before sending if you want them to be streamable. Unsupported formats will result in VideoContentTypeError. */ supportsStreaming?: boolean; /** See the {@link parseMode} property for allowed values. Markdown parsing will be used by default. */ parseMode?: any; /** A list of message formatting entities. When provided, the parseMode is ignored. */ formattingEntities?: Api.TypeMessageEntity[]; /** Whether the message should notify people in a broadcast channel or not. Defaults to false, which means it will notify them. Set it to True to alter this behaviour. */ silent?: boolean; /** * If set, the file won't send immediately, and instead it will be scheduled to be automatically sent at a later time. */ scheduleDate?: number; /** * The matrix (list of lists), row list or button to be shown after sending the message.<br/> * This parameter will only work if you have signed in as a bot. You can also pass your own ReplyMarkup here. */ buttons?: MarkupLike; /** How many workers to use to upload the file. anything above 16 is unstable. */ workers?: number; noforwards?: boolean; /** Similar to ``replyTo``, but replies in the linked group of a broadcast channel instead (effectively leaving a "comment to" the specified message). This parameter takes precedence over ``replyTo``. If there is no linked chat, `SG_ID_INVALID` is thrown. */ commentTo?: number | Api.Message; } interface FileToMediaInterface { file: FileLike; forceDocument?: boolean; fileSize?: number; progressCallback?: OnProgress; attributes?: Api.TypeDocumentAttribute[]; thumb?: FileLike; voiceNote?: boolean; videoNote?: boolean; supportsStreaming?: boolean; mimeType?: string; asImage?: boolean; workers?: number; } /** @hidden */ export async function _fileToMedia( client: TelegramClient, { file, forceDocument, fileSize, progressCallback, attributes, thumb, voiceNote = false, videoNote = false, supportsStreaming = false, mimeType, asImage, workers = 1, }: FileToMediaInterface ): Promise<{ fileHandle?: any; media?: Api.TypeInputMedia; image?: boolean; }> { if (!file) { return { fileHandle: undefined, media: undefined, image: undefined }; } const isImage = utils.isImage(file); if (asImage == undefined) { asImage = isImage && !forceDocument; } if ( typeof file == "object" && !Buffer.isBuffer(file) && !(file instanceof Api.InputFile) && !(file instanceof Api.InputFileBig) && !(file instanceof CustomFile) && !("read" in file) ) { try { return { fileHandle: undefined, media: utils.getInputMedia(file, { isPhoto: asImage, attributes: attributes, forceDocument: forceDocument, voiceNote: voiceNote, videoNote: videoNote, supportsStreaming: supportsStreaming, }), image: asImage, }; } catch (e) { return { fileHandle: undefined, media: undefined, image: isImage, }; } } let media; let fileHandle; let createdFile; if (file instanceof Api.InputFile || file instanceof Api.InputFileBig) { fileHandle = file; } else if ( typeof file == "string" && (file.startsWith("https://") || file.startsWith("http://")) ) { if (asImage) { media = new Api.InputMediaPhotoExternal({ url: file }); } else { media = new Api.InputMediaDocumentExternal({ url: file }); } } else if (!(typeof file == "string") || (await fs.lstat(file)).isFile()) { if (typeof file == "string") { createdFile = new CustomFile( path.basename(file), (await fs.stat(file)).size, file ); } else if ( (typeof File !== "undefined" && file instanceof File) || file instanceof CustomFile ) { createdFile = file; } else { let name; if ("name" in file) { // @ts-ignore name = file.name; } else { name = "unnamed"; } if (Buffer.isBuffer(file)) { createdFile = new CustomFile(name, file.length, "", file); } } if (!createdFile) { throw new Error( `Could not create file from ${JSON.stringify(file)}` ); } fileHandle = await uploadFile(client, { file: createdFile, onProgress: progressCallback, workers: workers, }); } else { throw new Error(`"Not a valid path nor a url ${file}`); } if (media != undefined) { } else if (fileHandle == undefined) { throw new Error( `Failed to convert ${file} to media. Not an existing file or an HTTP URL` ); } else if (asImage) { media = new Api.InputMediaUploadedPhoto({ file: fileHandle, }); } else { // @ts-ignore let res = utils.getAttributes(file, { mimeType: mimeType, attributes: attributes, forceDocument: forceDocument && !isImage, voiceNote: voiceNote, videoNote: videoNote, supportsStreaming: supportsStreaming, thumb: thumb, }); attributes = res.attrs; mimeType = res.mimeType; let uploadedThumb; if (!thumb) { uploadedThumb = undefined; } else { // todo refactor if (typeof thumb == "string") { uploadedThumb = new CustomFile( path.basename(thumb), (await fs.stat(thumb)).size, thumb ); } else if (typeof File !== "undefined" && thumb instanceof File) { uploadedThumb = thumb; } else { let name; if ("name" in thumb) { name = thumb.name; } else { name = "unnamed"; } if (Buffer.isBuffer(thumb)) { uploadedThumb = new CustomFile( name, thumb.length, "", thumb ); } } if (!uploadedThumb) { throw new Error(`Could not create file from ${file}`); } uploadedThumb = await uploadFile(client, { file: uploadedThumb, workers: 1, }); } media = new Api.InputMediaUploadedDocument({ file: fileHandle, mimeType: mimeType, attributes: attributes, thumb: uploadedThumb, forceFile: forceDocument && !isImage, }); } return { fileHandle: fileHandle, media: media, image: asImage, }; } /** @hidden */ export async function _sendAlbum( client: TelegramClient, entity: EntityLike, { file, caption, forceDocument = false, fileSize, clearDraft = false, progressCallback, replyTo, attributes, thumb, parseMode, voiceNote = false, videoNote = false, silent, supportsStreaming = false, scheduleDate, workers = 1, noforwards, commentTo, }: SendFileInterface ) { entity = await client.getInputEntity(entity); let files = []; if (!Array.isArray(file)) { files = [file]; } else { files = file; } if (!Array.isArray(caption)) { if (!caption) { caption = ""; } caption = [caption]; } const captions: [string, Api.TypeMessageEntity[]][] = []; for (const c of caption) { captions.push(await _parseMessageText(client, c, parseMode)); } if (commentTo != undefined) { const discussionData = await getCommentData(client, entity, commentTo); entity = discussionData.entity; replyTo = discussionData.replyTo; } else { replyTo = utils.getMessageId(replyTo); } const albumFiles = []; for (const file of files) { let { fileHandle, media, image } = await _fileToMedia(client, { file: file, forceDocument: forceDocument, fileSize: fileSize, progressCallback: progressCallback, attributes: attributes, thumb: thumb, voiceNote: voiceNote, videoNote: videoNote, supportsStreaming: supportsStreaming, workers: workers, }); if ( media instanceof Api.InputMediaUploadedPhoto || media instanceof Api.InputMediaPhotoExternal ) { const r = await client.invoke( new Api.messages.UploadMedia({ peer: entity, media, }) ); if (r instanceof Api.MessageMediaPhoto) { media = getInputMedia(r.photo); } } else if (media instanceof Api.InputMediaUploadedDocument) { const r = await client.invoke( new Api.messages.UploadMedia({ peer: entity, media, }) ); if (r instanceof Api.MessageMediaDocument) { media = getInputMedia(r.document); } } let text = ""; let msgEntities: Api.TypeMessageEntity[] = []; if (captions.length) { [text, msgEntities] = captions.shift()!; } albumFiles.push( new Api.InputSingleMedia({ media: media!, message: text, entities: msgEntities, }) ); } const result = await client.invoke( new Api.messages.SendMultiMedia({ peer: entity, replyToMsgId: replyTo, multiMedia: albumFiles, silent: silent, scheduleDate: scheduleDate, clearDraft: clearDraft, noforwards: noforwards, }) ); const randomIds = albumFiles.map((m) => m.randomId); return client._getResponseMessage(randomIds, result, entity) as Api.Message; } /** @hidden */ export async function sendFile( client: TelegramClient, entity: EntityLike, { file, caption, forceDocument = false, fileSize, clearDraft = false, progressCallback, replyTo, attributes, thumb, parseMode, formattingEntities, voiceNote = false, videoNote = false, buttons, silent, supportsStreaming = false, scheduleDate, workers = 1, noforwards, commentTo, }: SendFileInterface ) { if (!file) { throw new Error("You need to specify a file"); } if (!caption) { caption = ""; } entity = await client.getInputEntity(entity); if (commentTo != undefined) { const discussionData = await getCommentData(client, entity, commentTo); entity = discussionData.entity; replyTo = discussionData.replyTo; } else { replyTo = utils.getMessageId(replyTo); } if (Array.isArray(file)) { return await _sendAlbum(client, entity, { file: file, caption: caption, replyTo: replyTo, parseMode: parseMode, silent: silent, scheduleDate: scheduleDate, supportsStreaming: supportsStreaming, clearDraft: clearDraft, forceDocument: forceDocument, noforwards: noforwards, }); } if (Array.isArray(caption)) { caption = caption[0] || ""; } let msgEntities; if (formattingEntities != undefined) { msgEntities = formattingEntities; } else { [caption, msgEntities] = await _parseMessageText( client, caption, parseMode ); } const { fileHandle, media, image } = await _fileToMedia(client, { file: file, forceDocument: forceDocument, fileSize: fileSize, progressCallback: progressCallback, attributes: attributes, thumb: thumb, voiceNote: voiceNote, videoNote: videoNote, supportsStreaming: supportsStreaming, workers: workers, }); if (media == undefined) { throw new Error(`Cannot use ${file} as file.`); } const markup = client.buildReplyMarkup(buttons); const request = new Api.messages.SendMedia({ peer: entity, media: media, replyToMsgId: replyTo, message: caption, entities: msgEntities, replyMarkup: markup, silent: silent, scheduleDate: scheduleDate, clearDraft: clearDraft, noforwards: noforwards, }); const result = await client.invoke(request); return client._getResponseMessage(request, result, entity) as Api.Message; } function fileToBuffer(file: File | CustomFile): Promise<Buffer> | Buffer { if (typeof File !== "undefined" && file instanceof File) { return new Response(file).arrayBuffer() as Promise<Buffer>; } else if (file instanceof CustomFile) { if (file.buffer != undefined) { return file.buffer; } else { return fs.readFile(file.path) as unknown as Buffer; } } else { throw new Error("Could not create buffer from file " + file); } }