feat(client): sending document-like media (files, audios, videos, gifs, voices)

This commit is contained in:
teidesu 2021-04-10 13:28:02 +03:00
parent 0901f97e0d
commit 7e4142a572
8 changed files with 549 additions and 2 deletions

View file

@ -27,6 +27,7 @@ import { iterHistory } from './methods/messages/iter-history'
import { _parseEntities } from './methods/messages/parse-entities' import { _parseEntities } from './methods/messages/parse-entities'
import { searchGlobal } from './methods/messages/search-global' import { searchGlobal } from './methods/messages/search-global'
import { searchMessages } from './methods/messages/search-messages' import { searchMessages } from './methods/messages/search-messages'
import { sendMedia } from './methods/messages/send-media'
import { sendPhoto } from './methods/messages/send-photo' import { sendPhoto } from './methods/messages/send-photo'
import { sendText } from './methods/messages/send-text' import { sendText } from './methods/messages/send-text'
import { import {
@ -54,6 +55,7 @@ import {
Chat, Chat,
FileDownloadParameters, FileDownloadParameters,
InputFileLike, InputFileLike,
InputMediaLike,
InputPeerLike, InputPeerLike,
MaybeDynamic, MaybeDynamic,
Message, Message,
@ -738,6 +740,60 @@ export class TelegramClient extends BaseTelegramClient {
): AsyncIterableIterator<Message> { ): AsyncIterableIterator<Message> {
return searchMessages.apply(this, arguments) 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<Message> {
return sendMedia.apply(this, arguments)
}
/** /**
* Send a single photo * Send a single photo
* *

View file

@ -25,6 +25,7 @@ import {
UpdateFilter, UpdateFilter,
Message, Message,
ReplyMarkup, ReplyMarkup,
InputMediaLike
} from '../types' } from '../types'
// @copy // @copy

View file

@ -22,6 +22,11 @@ try {
const debug = require('debug')('mtcute:upload') const debug = require('debug')('mtcute:upload')
const OVERRIDE_MIME: Record<string, string> = {
// 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 * Upload a file to Telegram servers, without actually
* sending a message anywhere. Useful when an `InputFile` is required. * 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 { return {
inputFile, inputFile,
size: fileSize, size: fileSize,

View file

@ -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<Message> {
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)
}

View file

@ -5,8 +5,6 @@ import {
BotKeyboard, BotKeyboard,
ReplyMarkup, ReplyMarkup,
isUploadedFile, isUploadedFile,
filters,
Photo,
} from '../../types' } from '../../types'
import { tl } from '@mtcute/tl' import { tl } from '@mtcute/tl'
import { TelegramClient } from '../../client' import { TelegramClient } from '../../client'

View file

@ -8,3 +8,4 @@ export * from './video'
export * from './location' export * from './location'
export * from './voice' export * from './voice'
export * from './sticker' export * from './sticker'
export * from './input-media'

View file

@ -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<T extends InputMediaLike> = Omit<T, 'type' | 'file'>
/**
* Create an animation to be sent
*/
export function animation(
file: InputFileLike,
params?: OmitTypeAndFile<InputMediaVideo>
): InputMediaVideo {
return {
type: 'video',
file,
isAnimated: true,
...(params || {}),
}
}
/**
* Create an audio to be sent
*/
export function audio(
file: InputFileLike,
params?: OmitTypeAndFile<InputMediaAudio>
): InputMediaAudio {
return {
type: 'audio',
file,
...(params || {}),
}
}
/**
* Create an document to be sent
*/
export function document(
file: InputFileLike,
params?: OmitTypeAndFile<InputMediaDocument>
): InputMediaDocument {
return {
type: 'document',
file,
...(params || {}),
}
}
/**
* Create an photo to be sent
*/
export function photo(
file: InputFileLike,
params?: OmitTypeAndFile<InputMediaPhoto>
): InputMediaPhoto {
return {
type: 'photo',
file,
...(params || {}),
}
}
/**
* Create an video to be sent
*/
export function video(
file: InputFileLike,
params?: OmitTypeAndFile<InputMediaVideo>
): InputMediaVideo {
return {
type: 'video',
file,
...(params || {}),
}
}
/**
* Create a voice message to be sent
*/
export function voice(
file: InputFileLike,
params?: OmitTypeAndFile<InputMediaVoice>
): 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>
): InputMediaAuto {
return {
type: 'auto',
file,
...(params || {}),
}
}
}

View file

@ -98,3 +98,11 @@ export function svgPathToFile(path: string): Buffer {
'</svg>' '</svg>'
) )
} }
const FILENAME_REGEX = /^(\/?.+[/\\])*(.+\..+)$/
export function extractFileName(path: string): string {
const m = path.match(FILENAME_REGEX)
if (m) return m[2]
return ''
}