diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index f3b53013..989adf1b 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -27,6 +27,7 @@ import { iterHistory } from './methods/messages/iter-history' import { _parseEntities } from './methods/messages/parse-entities' import { searchGlobal } from './methods/messages/search-global' import { searchMessages } from './methods/messages/search-messages' +import { sendMedia } from './methods/messages/send-media' import { sendPhoto } from './methods/messages/send-photo' import { sendText } from './methods/messages/send-text' import { @@ -54,6 +55,7 @@ import { Chat, FileDownloadParameters, InputFileLike, + InputMediaLike, InputPeerLike, MaybeDynamic, Message, @@ -738,6 +740,60 @@ export class TelegramClient extends BaseTelegramClient { ): AsyncIterableIterator { return searchMessages.apply(this, arguments) } + /** + * Send a single media. + * + * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` + * @param media Media contained in the message + * @param params Additional sending parameters + */ + sendMedia( + chatId: InputPeerLike, + media: InputMediaLike, + params?: { + /** + * Message to reply to. Either a message object or message ID. + */ + replyTo?: number | Message + + /** + * Parse mode to use to parse entities before sending + * the message. Defaults to current default parse mode (if any). + * + * Passing `null` will explicitly disable formatting. + */ + parseMode?: string | null + + /** + * Whether to send this message silently. + */ + silent?: boolean + + /** + * If set, the message will be scheduled to this date. + * When passing a number, a UNIX time in ms is expected. + */ + schedule?: Date | number + + /** + * For bots: inline or reply markup or an instruction + * to hide a reply keyboard or to force a reply. + */ + replyMarkup?: ReplyMarkup + + /** + * Function that will be called after some part has been uploaded. + * Only used when a file that requires uploading is passed, + * and not used when uploading a thumbnail. + * + * @param uploaded Number of bytes already uploaded + * @param total Total file size + */ + progressCallback?: (uploaded: number, total: number) => void + } + ): Promise { + return sendMedia.apply(this, arguments) + } /** * Send a single photo * diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index fd7b0f35..1e1d5103 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -25,6 +25,7 @@ import { UpdateFilter, Message, ReplyMarkup, + InputMediaLike } from '../types' // @copy diff --git a/packages/client/src/methods/files/upload-file.ts b/packages/client/src/methods/files/upload-file.ts index f3558894..6d4d7adc 100644 --- a/packages/client/src/methods/files/upload-file.ts +++ b/packages/client/src/methods/files/upload-file.ts @@ -22,6 +22,11 @@ try { const debug = require('debug')('mtcute:upload') +const OVERRIDE_MIME: Record = { + // tg doesn't interpret `audio/opus` files as voice messages for some reason + 'audio/opus': 'audio/ogg' +} + /** * Upload a file to Telegram servers, without actually * sending a message anywhere. Useful when an `InputFile` is required. @@ -247,6 +252,8 @@ export async function uploadFile( } } + if (fileMime! in OVERRIDE_MIME) fileMime = OVERRIDE_MIME[fileMime!] + return { inputFile, size: fileSize, diff --git a/packages/client/src/methods/messages/send-media.ts b/packages/client/src/methods/messages/send-media.ts new file mode 100644 index 00000000..877c953f --- /dev/null +++ b/packages/client/src/methods/messages/send-media.ts @@ -0,0 +1,201 @@ +import { TelegramClient } from '../../client' +import { + BotKeyboard, + InputMediaLike, + InputPeerLike, + isUploadedFile, + Message, + MtCuteArgumentError, + ReplyMarkup, +} from '../../types' +import { tl } from '@mtcute/tl' +import { extractFileName } from '../../utils/file-utils' +import { normalizeToInputPeer } from '../../utils/peer-utils' +import { normalizeDate, randomUlong } from '../../utils/misc-utils' + +/** + * Send a single media. + * + * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` + * @param media Media contained in the message + * @param params Additional sending parameters + * @internal + */ +export async function sendMedia( + this: TelegramClient, + chatId: InputPeerLike, + media: InputMediaLike, + params?: { + /** + * Message to reply to. Either a message object or message ID. + */ + replyTo?: number | Message + + /** + * Parse mode to use to parse entities before sending + * the message. Defaults to current default parse mode (if any). + * + * Passing `null` will explicitly disable formatting. + */ + parseMode?: string | null + + /** + * Whether to send this message silently. + */ + silent?: boolean + + /** + * If set, the message will be scheduled to this date. + * When passing a number, a UNIX time in ms is expected. + */ + schedule?: Date | number + + /** + * For bots: inline or reply markup or an instruction + * to hide a reply keyboard or to force a reply. + */ + replyMarkup?: ReplyMarkup + + /** + * Function that will be called after some part has been uploaded. + * Only used when a file that requires uploading is passed, + * and not used when uploading a thumbnail. + * + * @param uploaded Number of bytes already uploaded + * @param total Total file size + */ + progressCallback?: (uploaded: number, total: number) => void + } +): Promise { + if (!params) params = {} + + if (media.type === 'photo') { + return this.sendPhoto(chatId, media.file, { + caption: media.caption, + entities: media.entities, + ...params, + }) + } + + let inputMedia: tl.TypeInputMedia | null = null + + let inputFile: tl.TypeInputFile | undefined = undefined + let thumb: tl.TypeInputFile | undefined = undefined + let mime = 'application/octet-stream' + + const input = media.file + if (typeof input === 'string' && input.match(/^https?:\/\//)) { + inputMedia = { + _: 'inputMediaDocumentExternal', + url: input, + } + } else if (isUploadedFile(input)) { + inputFile = input.inputFile + mime = input.mime + } else if (typeof input === 'object' && tl.isAnyInputFile(input)) { + inputFile = input + } else { + const uploaded = await this.uploadFile({ + file: input, + fileName: media.fileName, + progressCallback: params.progressCallback, + }) + inputFile = uploaded.inputFile + mime = uploaded.mime + } + + if (!inputMedia) { + if (!inputFile) throw new Error('should not happen') + + if ('thumb' in media && media.thumb) { + const t = media.thumb + if (typeof t === 'string' && t.match(/^https?:\/\//)) { + throw new MtCuteArgumentError("Thumbnail can't be external") + } else if (isUploadedFile(t)) { + thumb = t.inputFile + } else if (typeof t === 'object' && tl.isAnyInputFile(t)) { + thumb = t + } else { + const uploaded = await this.uploadFile({ + file: t, + }) + thumb = uploaded.inputFile + } + } + + const attributes: tl.TypeDocumentAttribute[] = [] + + if (media.type !== 'voice') { + attributes.push({ + _: 'documentAttributeFilename', + fileName: + media.fileName || + (typeof media.file === 'string' + ? extractFileName(media.file) + : 'unnamed'), + }) + } + + if (media.type === 'video') { + attributes.push({ + _: 'documentAttributeVideo', + duration: media.duration || 0, + w: media.width || 0, + h: media.height || 0, + supportsStreaming: media.supportsStreaming, + roundMessage: media.isRound + }) + if (media.isAnimated) attributes.push({ _: 'documentAttributeAnimated' }) + } + + if (media.type === 'audio' || media.type === 'voice') { + attributes.push({ + _: 'documentAttributeAudio', + voice: media.type === 'voice', + duration: media.duration || 0, + title: media.type === 'audio' ? media.title : undefined, + performer: media.type === 'audio' ? media.performer : undefined, + waveform: media.type === 'voice' ? media.waveform : undefined + }) + } + + inputMedia = { + _: 'inputMediaUploadedDocument', + nosoundVideo: media.type === 'video' && media.isAnimated, + forceFile: media.type === 'document', + file: inputFile, + thumb, + mimeType: mime, + attributes + } + } + + const [message, entities] = await this._parseEntities( + media.caption, + params.parseMode, + media.entities + ) + + const peer = normalizeToInputPeer(await this.resolvePeer(chatId)) + const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) + + + const res = await this.call({ + _: 'messages.sendMedia', + peer, + media: inputMedia, + silent: params.silent, + replyToMsgId: params.replyTo + ? typeof params.replyTo === 'number' + ? params.replyTo + : params.replyTo.id + : undefined, + randomId: randomUlong(), + scheduleDate: normalizeDate(params.schedule), + replyMarkup, + message, + entities, + }) + + return this._findMessageInUpdate(res) +} diff --git a/packages/client/src/methods/messages/send-photo.ts b/packages/client/src/methods/messages/send-photo.ts index de749d10..2050a68d 100644 --- a/packages/client/src/methods/messages/send-photo.ts +++ b/packages/client/src/methods/messages/send-photo.ts @@ -5,8 +5,6 @@ import { BotKeyboard, ReplyMarkup, isUploadedFile, - filters, - Photo, } from '../../types' import { tl } from '@mtcute/tl' import { TelegramClient } from '../../client' diff --git a/packages/client/src/types/media/index.ts b/packages/client/src/types/media/index.ts index 956c8556..5a58afd4 100644 --- a/packages/client/src/types/media/index.ts +++ b/packages/client/src/types/media/index.ts @@ -8,3 +8,4 @@ export * from './video' export * from './location' export * from './voice' export * from './sticker' +export * from './input-media' diff --git a/packages/client/src/types/media/input-media.ts b/packages/client/src/types/media/input-media.ts new file mode 100644 index 00000000..53a1a49c --- /dev/null +++ b/packages/client/src/types/media/input-media.ts @@ -0,0 +1,275 @@ +import { InputFileLike } from '../files' +import { tl } from '@mtcute/tl' + +interface BaseInputMedia { + /** + * File to be sent + */ + file: InputFileLike + + /** + * Caption of the media + */ + caption?: string + + /** + * Caption entities of the media. + * If passed, {@link caption} is ignored + */ + entities?: tl.TypeMessageEntity[] + + /** + * Override file name for the file. + */ + fileName?: string +} + +/** + * Automatically detect media type based on file contents. + * + * Only works for files that are internally documents, i.e. + * *does not* infer photos, so use {@link InputMediaPhoto} instead. + */ +export interface InputMediaAuto extends BaseInputMedia { + type: 'auto' +} + +/** + * An audio file or voice message to be sent + */ +export interface InputMediaAudio extends BaseInputMedia { + type: 'audio' + + /** + * Thumbnail of the audio file album cover. + * + * The thumbnail should be in JPEG format and less than 200 KB in size. + * A thumbnail's width and height should not exceed 320 pixels. + * Thumbnails can't be reused and can be only uploaded as a new file. + */ + thumb?: InputFileLike + + /** + * Duration of the audio in seconds + */ + duration?: number + + /** + * Performer of the audio + */ + performer?: string + + /** + * Title of the audio + */ + title?: string +} + +/** + * Voice message to be sent + */ +export interface InputMediaVoice extends BaseInputMedia { + type: 'voice' + + /** + * Duration of the voice message in seconds + */ + duration?: number + + /** + * Waveform of the voice message + */ + waveform?: Buffer +} + +/** + * A generic file to be sent + */ +export interface InputMediaDocument extends BaseInputMedia { + type: 'document' + + /** + * Thumbnail of the document. + * + * The thumbnail should be in JPEG format and less than 200 KB in size. + * A thumbnail's width and height should not exceed 320 pixels. + * Thumbnails can't be reused and can be only uploaded as a new file. + */ + thumb?: InputFileLike +} + +/** + * A photo to be sent + */ +export interface InputMediaPhoto extends BaseInputMedia { + type: 'photo' +} + +/** + * A video to be sent + */ +export interface InputMediaVideo extends BaseInputMedia { + type: 'video' + + /** + * Thumbnail of the video. + * + * The thumbnail should be in JPEG format and less than 200 KB in size. + * A thumbnail's width and height should not exceed 320 pixels. + * Thumbnails can't be reused and can be only uploaded as a new file. + */ + thumb?: InputFileLike + + /** + * Width of the video in pixels + */ + width?: number + + /** + * Height of the video in pixels + */ + height?: number + + /** + * Duration of the video in seconds + */ + duration?: number + + /** + * Whether the video is suitable for streaming + */ + supportsStreaming?: boolean + + /** + * Whether this video is an animated GIF + */ + isAnimated?: boolean + + /** + * Whether this video is a round message (aka video note) + */ + isRound?: boolean +} + +/** + * Input media that can be sent somewhere. + * + * Note that meta-fields (like `duration`) are only + * applicable if `file` is {@link UploadFileLike}, + * otherwise they are ignored. + * + * @see InputMedia + */ +export type InputMediaLike = + | InputMediaAudio + | InputMediaVoice + | InputMediaDocument + | InputMediaPhoto + | InputMediaVideo + | InputMediaAuto + +export namespace InputMedia { + type OmitTypeAndFile = Omit + + /** + * Create an animation to be sent + */ + export function animation( + file: InputFileLike, + params?: OmitTypeAndFile + ): InputMediaVideo { + return { + type: 'video', + file, + isAnimated: true, + ...(params || {}), + } + } + + /** + * Create an audio to be sent + */ + export function audio( + file: InputFileLike, + params?: OmitTypeAndFile + ): InputMediaAudio { + return { + type: 'audio', + file, + ...(params || {}), + } + } + + /** + * Create an document to be sent + */ + export function document( + file: InputFileLike, + params?: OmitTypeAndFile + ): InputMediaDocument { + return { + type: 'document', + file, + ...(params || {}), + } + } + + /** + * Create an photo to be sent + */ + export function photo( + file: InputFileLike, + params?: OmitTypeAndFile + ): InputMediaPhoto { + return { + type: 'photo', + file, + ...(params || {}), + } + } + + /** + * Create an video to be sent + */ + export function video( + file: InputFileLike, + params?: OmitTypeAndFile + ): InputMediaVideo { + return { + type: 'video', + file, + ...(params || {}), + } + } + + /** + * Create a voice message to be sent + */ + export function voice( + file: InputFileLike, + params?: OmitTypeAndFile + ): InputMediaVoice { + return { + type: 'voice', + file, + ...(params || {}), + } + } + + /** + * Create a document to be sent, which subtype + * is inferred automatically by file contents. + * + * **Does not** infer photos, they will be sent as simple files. + */ + export function auto( + file: InputFileLike, + params?: OmitTypeAndFile + ): InputMediaAuto { + return { + type: 'auto', + file, + ...(params || {}), + } + } +} diff --git a/packages/client/src/utils/file-utils.ts b/packages/client/src/utils/file-utils.ts index 1a463351..ae140b49 100644 --- a/packages/client/src/utils/file-utils.ts +++ b/packages/client/src/utils/file-utils.ts @@ -98,3 +98,11 @@ export function svgPathToFile(path: string): Buffer { '' ) } + +const FILENAME_REGEX = /^(\/?.+[/\\])*(.+\..+)$/ + +export function extractFileName(path: string): string { + const m = path.match(FILENAME_REGEX) + if (m) return m[2] + return '' +}