refactor(client): extracted input file and media normalization to own methods, merged sendPhoto and sendMedia methods

This commit is contained in:
teidesu 2021-04-30 23:57:24 +03:00
parent 0eb0ac91eb
commit a67c4ae85e
6 changed files with 283 additions and 444 deletions

View file

@ -55,6 +55,8 @@ import { downloadAsBuffer } from './methods/files/download-buffer'
import { downloadToFile } from './methods/files/download-file'
import { downloadAsIterable } from './methods/files/download-iterable'
import { downloadAsStream } from './methods/files/download-stream'
import { _normalizeInputFile } from './methods/files/normalize-input-file'
import { _normalizeInputMedia } from './methods/files/normalize-input-media'
import { uploadFile } from './methods/files/upload-file'
import { deleteMessages } from './methods/messages/delete-messages'
import { editMessage } from './methods/messages/edit-message'
@ -70,7 +72,6 @@ import { searchMessages } from './methods/messages/search-messages'
import { sendDice } from './methods/messages/send-dice'
import { sendLocation } from './methods/messages/send-location'
import { sendMedia } from './methods/messages/send-media'
import { sendPhoto } from './methods/messages/send-photo'
import { sendText } from './methods/messages/send-text'
import { unpinMessage } from './methods/messages/unpin-message'
import { initTakeoutSession } from './methods/misc/init-takeout-session'
@ -1600,7 +1601,7 @@ export interface TelegramClient extends BaseTelegramClient {
}
): Promise<Message>
/**
* Send a single media.
* Send a single media (a photo or a document-based media)
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param media
@ -1608,6 +1609,7 @@ export interface TelegramClient extends BaseTelegramClient {
* and Bot API compatible File ID, which will be wrapped
* in {@link InputMedia.auto}
* @param params Additional sending parameters
* @see InputMedia
*/
sendMedia(
chatId: InputPeerLike,
@ -1661,84 +1663,6 @@ export interface TelegramClient extends BaseTelegramClient {
clearDraft?: boolean
}
): Promise<Message>
/**
* Send a single photo
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param photo Photo contained in the message.
* @param params Additional sending parameters
*/
sendPhoto(
chatId: InputPeerLike,
photo: InputFileLike,
params?: {
/**
* Caption for the photo
*/
caption?: string
/**
* 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
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/**
* 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
/**
* Self-Destruct timer.
* If set, the photo will self-destruct in a given number
* of seconds.
*/
ttlSeconds?: number
/**
* Function that will be called after some part has been uploaded.
* Only used when a file that requires uploading is passed.
*
* @param uploaded Number of bytes already uploaded
* @param total Total file size
*/
progressCallback?: (uploaded: number, total: number) => void
/**
* Whether to clear draft after sending this message.
*
* Defaults to `false`
*/
clearDraft?: boolean
}
): Promise<Message>
/**
* Send a text message
*
@ -2024,6 +1948,8 @@ export class TelegramClient extends BaseTelegramClient {
downloadToFile = downloadToFile
downloadAsIterable = downloadAsIterable
downloadAsStream = downloadAsStream
protected _normalizeInputFile = _normalizeInputFile
protected _normalizeInputMedia = _normalizeInputMedia
uploadFile = uploadFile
deleteMessages = deleteMessages
editMessage = editMessage
@ -2039,7 +1965,6 @@ export class TelegramClient extends BaseTelegramClient {
sendDice = sendDice
sendLocation = sendLocation
sendMedia = sendMedia
sendPhoto = sendPhoto
sendText = sendText
unpinMessage = unpinMessage
initTakeoutSession = initTakeoutSession

View file

@ -0,0 +1,49 @@
import { TelegramClient } from '../../client'
import { InputFileLike, isUploadedFile, MtCuteArgumentError } from '../../types'
import { tl } from '@mtcute/tl'
import { tdFileId } from '@mtcute/file-id'
/**
* Normalize a {@link InputFileLike} to `InputFile`,
* uploading it if needed.
*
* @internal
*/
export async function _normalizeInputFile(
this: TelegramClient,
input: InputFileLike,
params: {
progressCallback?: (uploaded: number, total: number) => void
fileName?: string
fileSize?: number
fileMime?: string
}
): Promise<tl.TypeInputFile> {
if (typeof input === 'object' && tl.isAnyInputMedia(input)) {
throw new MtCuteArgumentError(
"InputFile can't be created from an InputMedia"
)
} else if (tdFileId.isFileIdLike(input)) {
if (typeof input === 'string' && input.match(/^file:/)) {
const uploaded = await this.uploadFile({
file: input.substr(5),
...params,
})
return uploaded.inputFile
} else {
throw new MtCuteArgumentError(
"InputFile can't be created from an URL or a File ID"
)
}
} else if (isUploadedFile(input)) {
return input.inputFile
} else if (typeof input === 'object' && tl.isAnyInputFile(input)) {
return input
} else {
const uploaded = await this.uploadFile({
file: input,
...params,
})
return uploaded.inputFile
}
}

View file

@ -0,0 +1,171 @@
import { TelegramClient } from '../../client'
import {
InputMediaLike,
isUploadedFile,
MtCuteArgumentError,
UploadFileLike,
} from '../../types'
import { tl } from '@mtcute/tl'
import {
fileIdToInputDocument,
fileIdToInputPhoto,
parseFileId,
tdFileId,
} from '@mtcute/file-id'
import { extractFileName } from '../../utils/file-utils'
/**
* Normalize an {@link InputMediaLike} to `InputMedia`,
* uploading the file if needed.
*
* @internal
*/
export async function _normalizeInputMedia(
this: TelegramClient,
media: InputMediaLike,
params: {
progressCallback?: (uploaded: number, total: number) => void
}
): Promise<tl.TypeInputMedia> {
// my condolences to those poor souls who are going to maintain this (myself included)
let inputFile: tl.TypeInputFile | undefined = undefined
let thumb: tl.TypeInputFile | undefined = undefined
let mime = 'application/octet-stream'
const upload = async (file: UploadFileLike): Promise<void> => {
const uploaded = await this.uploadFile({
file,
progressCallback: params.progressCallback,
fileName: media.fileName,
fileMime:
media.type === 'sticker'
? media.isAnimated
? 'application/x-tgsticker'
: 'image/webp'
: media.fileMime,
fileSize: media.fileSize,
})
inputFile = uploaded.inputFile
mime = uploaded.mime
}
const input = media.file
if (tdFileId.isFileIdLike(input)) {
if (typeof input === 'string' && input.match(/^https?:\/\//)) {
return {
_:
media.type === 'photo'
? 'inputMediaPhotoExternal'
: 'inputMediaDocumentExternal',
url: input,
}
} else if (typeof input === 'string' && input.match(/^file:/)) {
await upload(input.substr(5))
} else {
const parsed =
typeof input === 'string' ? parseFileId(input) : input
if (parsed.location._ === 'photo') {
return {
_: 'inputMediaPhoto',
id: fileIdToInputPhoto(parsed),
}
} else if (parsed.location._ === 'web') {
return {
_:
parsed.type === tdFileId.FileType.Photo
? 'inputMediaPhotoExternal'
: 'inputMediaDocumentExternal',
url: parsed.location.url,
}
} else {
return {
_: 'inputMediaDocument',
id: fileIdToInputDocument(parsed),
}
}
}
} else if (typeof input === 'object' && tl.isAnyInputMedia(input)) {
return input
} else if (isUploadedFile(input)) {
inputFile = input.inputFile
mime = input.mime
} else if (typeof input === 'object' && tl.isAnyInputFile(input)) {
inputFile = input
} else {
await upload(input)
}
if (!inputFile) throw new Error('should not happen')
if (media.type === 'photo') {
return {
_: 'inputMediaUploadedPhoto',
file: inputFile,
ttlSeconds: media.ttlSeconds,
}
}
if ('thumb' in media && media.thumb) {
thumb = await this._normalizeInputFile(media.thumb, {})
}
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,
})
}
if (media.type === 'sticker') {
attributes.push({
_: 'documentAttributeSticker',
stickerset: {
_: 'inputStickerSetEmpty',
},
alt: media.alt ?? '',
})
}
return {
_: 'inputMediaUploadedDocument',
nosoundVideo: media.type === 'video' && media.isAnimated,
forceFile: media.type === 'document',
file: inputFile,
thumb,
mimeType: mime,
attributes,
ttlSeconds: media.ttlSeconds
}
}

View file

@ -3,24 +3,14 @@ import {
BotKeyboard,
InputMediaLike,
InputPeerLike,
isUploadedFile,
Message,
MtCuteArgumentError,
ReplyMarkup, UploadFileLike,
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'
import {
fileIdToInputDocument,
fileIdToInputPhoto,
parseFileId,
tdFileId,
} from '@mtcute/file-id'
/**
* Send a single media.
* Send a single media (a photo or a document-based media)
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param media
@ -28,6 +18,7 @@ import {
* and Bot API compatible File ID, which will be wrapped
* in {@link InputMedia.auto}
* @param params Additional sending parameters
* @see InputMedia
* @internal
*/
export async function sendMedia(
@ -92,170 +83,7 @@ export async function sendMedia(
}
}
if (media.type === 'photo') {
return this.sendPhoto(chatId, media.file, {
caption: media.caption,
entities: media.entities,
...params,
})
}
let inputMedia: tl.TypeInputMedia | null = null
// my condolences to those poor souls who are going to maintain this (myself included)
let inputFile: tl.TypeInputFile | undefined = undefined
let thumb: tl.TypeInputFile | undefined = undefined
let mime = 'application/octet-stream'
const upload = async (media: InputMediaLike, file: UploadFileLike): Promise<void> => {
const uploaded = await this.uploadFile({
file,
fileName: media.fileName,
progressCallback: params!.progressCallback,
fileMime:
media.type === 'sticker'
? media.isAnimated
? 'application/x-tgsticker'
: 'image/webp'
: media.mime,
fileSize: media.fileSize
})
inputFile = uploaded.inputFile
mime = uploaded.mime
}
const input = media.file
if (tdFileId.isFileIdLike(input)) {
if (typeof input === 'string' && input.match(/^https?:\/\//)) {
inputMedia = {
_: 'inputMediaDocumentExternal',
url: input,
}
} else if (typeof input === 'string' && input.match(/^file:/)) {
await upload(media, input.substr(5))
} else {
const parsed =
typeof input === 'string' ? parseFileId(input) : input
if (parsed.location._ === 'photo') {
inputMedia = {
_: 'inputMediaPhoto',
id: fileIdToInputPhoto(parsed),
}
} else if (parsed.location._ === 'web') {
inputMedia = {
_:
parsed.type === tdFileId.FileType.Photo
? 'inputMediaPhotoExternal'
: 'inputMediaDocumentExternal',
url: parsed.location.url,
}
} else {
inputMedia = {
_: 'inputMediaDocument',
id: fileIdToInputDocument(parsed),
}
}
}
} else if (typeof input === 'object' && tl.isAnyInputMedia(input)) {
inputMedia = input
} else if (isUploadedFile(input)) {
inputFile = input.inputFile
mime = input.mime
} else if (typeof input === 'object' && tl.isAnyInputFile(input)) {
inputFile = input
} else {
await upload(media, input)
}
if (!inputMedia) {
if (!inputFile) throw new Error('should not happen')
if ('thumb' in media && media.thumb) {
const t = media.thumb
if (typeof t === 'object' && tl.isAnyInputMedia(t)) {
throw new MtCuteArgumentError("Thumbnail can't be InputMedia")
} else if (tdFileId.isFileIdLike(t)) {
if (typeof t === 'string' && t.match(/^file:/)) {
const uploaded = await this.uploadFile({
file: t.substr(5),
})
thumb = uploaded.inputFile
} else {
throw new MtCuteArgumentError(
"Thumbnail can't be a URL or a File ID"
)
}
} 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,
})
}
if (media.type === 'sticker') {
attributes.push({
_: 'documentAttributeSticker',
stickerset: {
_: 'inputStickerSetEmpty',
},
alt: media.alt ?? '',
})
}
inputMedia = {
_: 'inputMediaUploadedDocument',
nosoundVideo: media.type === 'video' && media.isAnimated,
forceFile: media.type === 'document',
file: inputFile,
thumb,
mimeType: mime,
attributes,
}
}
const inputMedia = await this._normalizeInputMedia(media, params)
const [message, entities] = await this._parseEntities(
media.caption,

View file

@ -1,181 +0,0 @@
import {
InputPeerLike,
InputFileLike,
Message,
BotKeyboard,
ReplyMarkup,
isUploadedFile,
UploadFileLike,
} from '../../types'
import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client'
import { normalizeToInputPeer } from '../../utils/peer-utils'
import { normalizeDate, randomUlong } from '../../utils/misc-utils'
import { fileIdToInputPhoto, tdFileId } from '@mtcute/file-id'
/**
* Send a single photo
*
* @param chatId ID of the chat, its username, phone or `"me"` or `"self"`
* @param photo Photo contained in the message.
* @param params Additional sending parameters
* @internal
*/
export async function sendPhoto(
this: TelegramClient,
chatId: InputPeerLike,
photo: InputFileLike,
params?: {
/**
* Caption for the photo
*/
caption?: string
/**
* 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
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/**
* 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
/**
* Self-Destruct timer.
* If set, the photo will self-destruct in a given number
* of seconds.
*/
ttlSeconds?: number
/**
* Function that will be called after some part has been uploaded.
* Only used when a file that requires uploading is passed.
*
* @param uploaded Number of bytes already uploaded
* @param total Total file size
*/
progressCallback?: (uploaded: number, total: number) => void
/**
* Whether to clear draft after sending this message.
*
* Defaults to `false`
*/
clearDraft?: boolean
/**
* File size. Only used when uploading from streams without
* known length.
*/
fileSize?: number
}
): Promise<Message> {
if (!params) params = {}
let media: tl.TypeInputMedia
const upload = async (photo: UploadFileLike) => {
const uploaded = await this.uploadFile({
file: photo,
progressCallback: params!.progressCallback,
fileSize: params!.fileSize,
})
media = {
_: 'inputMediaUploadedPhoto',
file: uploaded.inputFile,
ttlSeconds: params!.ttlSeconds,
}
}
if (tdFileId.isFileIdLike(photo)) {
if (typeof photo === 'string' && photo.match(/^https?:\/\//)) {
media = {
_: 'inputMediaPhotoExternal',
url: photo,
ttlSeconds: params.ttlSeconds,
}
} else if (typeof photo === 'string' && photo.match(/^file:/)) {
await upload(photo.substr(5))
} else {
const input = fileIdToInputPhoto(photo)
media = {
_: 'inputMediaPhoto',
id: input,
}
}
} else if (typeof photo === 'object' && tl.isAnyInputMedia(photo)) {
media = photo
} else if (isUploadedFile(photo)) {
media = {
_: 'inputMediaUploadedPhoto',
file: photo.inputFile,
ttlSeconds: params.ttlSeconds,
}
} else if (typeof photo === 'object' && tl.isAnyInputFile(photo)) {
media = {
_: 'inputMediaUploadedPhoto',
file: photo,
ttlSeconds: params.ttlSeconds,
}
} else {
await upload(photo)
}
const [message, entities] = await this._parseEntities(
params.caption,
params.parseMode,
params.entities
)
const peer = normalizeToInputPeer(await this.resolvePeer(chatId))
const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup)
const res = await this.call({
_: 'messages.sendMedia',
media: media!,
peer,
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,
clearDraft: params.clearDraft,
})
return this._findMessageInUpdate(res)
}

View file

@ -20,26 +20,39 @@ interface BaseInputMedia {
/**
* Override file name for the file.
*
* Only applicable to newly uploaded files.
*/
fileName?: string
/**
* Override MIME type for the file
*
* Only applicable to newly uploaded files.
*/
mime?: string
fileMime?: string
/**
* Override file size for the file
*
* Only applicable to newly uploaded files.
*/
fileSize?: number
/**
* TTL for the media in seconds.
*
* Only applicable to some media types
*/
ttlSeconds?: number
}
/**
* 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
* (except for File IDs, from which photos *are* inferred)
* Photo type is only inferred for reused files,
* newly uploaded photos with `auto` will be
* uploaded as a document
*/
export interface InputMediaAuto extends BaseInputMedia {
type: 'auto'
@ -57,21 +70,29 @@ export interface InputMediaAudio extends BaseInputMedia {
* 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.
*
* Only applicable to newly uploaded files.
*/
thumb?: InputFileLike
/**
* Duration of the audio in seconds
*
* Only applicable to newly uploaded files.
*/
duration?: number
/**
* Performer of the audio
*
* Only applicable to newly uploaded files.
*/
performer?: string
/**
* Title of the audio
*
* Only applicable to newly uploaded files.
*/
title?: string
}
@ -84,11 +105,15 @@ export interface InputMediaVoice extends BaseInputMedia {
/**
* Duration of the voice message in seconds
*
* Only applicable to newly uploaded files.
*/
duration?: number
/**
* Waveform of the voice message
*
* Only applicable to newly uploaded files.
*/
waveform?: Buffer
}
@ -105,6 +130,8 @@ export interface InputMediaDocument extends BaseInputMedia {
* 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.
*
* Only applicable to newly uploaded files.
*/
thumb?: InputFileLike
}
@ -132,11 +159,16 @@ export interface InputMediaSticker extends BaseInputMedia {
* format, which is Lottie JSON compressed using GZip
*
* Defaults to `false`
*
* Only applicable to newly uploaded files.
*/
isAnimated?: boolean
/**
* An emoji representing this sticker
*
* Only applicable to newly uploaded files,
* for some reason doesn't work with animated stickers.
*/
alt?: string
}
@ -153,36 +185,50 @@ export interface InputMediaVideo extends BaseInputMedia {
* 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.
*
* Only applicable to newly uploaded files.
*/
thumb?: InputFileLike
/**
* Width of the video in pixels
*
* Only applicable to newly uploaded files.
*/
width?: number
/**
* Height of the video in pixels
*
* Only applicable to newly uploaded files.
*/
height?: number
/**
* Duration of the video in seconds
*
* Only applicable to newly uploaded files.
*/
duration?: number
/**
* Whether the video is suitable for streaming
*
* Only applicable to newly uploaded files.
*/
supportsStreaming?: boolean
/**
* Whether this video is an animated GIF
*
* Only applicable to newly uploaded files.
*/
isAnimated?: boolean
/**
* Whether this video is a round message (aka video note)
*
* Only applicable to newly uploaded files.
*/
isRound?: boolean
}
@ -311,8 +357,9 @@ export namespace InputMedia {
* Create a document to be sent, which subtype
* is inferred automatically by file contents.
*
* Only infers photos from the File ID, otherwise
* photos will be sent as documents.
* Photo type is only inferred for reused files,
* newly uploaded photos with `auto` will be
* uploaded as a document
*/
export function auto(
file: InputFileLike,