diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index d28a01eb..7b9bf3bc 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -72,8 +72,6 @@ import { _parseEntities } from './methods/messages/parse-entities' import { pinMessage } from './methods/messages/pin-message' import { searchGlobal } from './methods/messages/search-global' import { searchMessages } from './methods/messages/search-messages' -import { sendDice } from './methods/messages/send-dice' -import { sendLocation } from './methods/messages/send-location' import { sendMediaGroup } from './methods/messages/send-media-group' import { sendMedia } from './methods/messages/send-media' import { sendText } from './methods/messages/send-text' @@ -1662,83 +1660,6 @@ export interface TelegramClient extends BaseTelegramClient { chunkSize?: number } ): AsyncIterableIterator - /** - * Send an animated dice with a random value. - * - * For convenience, known dice emojis are available - * as static members of {@link Dice}. - * - * Note that dice result value is generated randomly on the server, - * you can't influence it in any way! - * - * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` - * @param emoji Emoji representing a dice - * @param params Additional sending parameters - * @link Dice - */ - sendDice( - chatId: InputPeerLike, - emoji: string, - params?: { - /** - * Message to reply to. Either a message object or message ID. - */ - replyTo?: number | Message - - /** - * 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 - } - ): Promise - /** - * Send a static geo location. - * - * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` - * @param latitude Latitude of the location - * @param longitude Longitude of the location - * @param params Additional sending parameters - */ - sendLocation( - chatId: InputPeerLike, - latitude: number, - longitude: number, - params?: { - /** - * Message to reply to. Either a message object or message ID. - */ - replyTo?: number | Message - - /** - * 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 - } - ): Promise /** * Send a group of media. * @@ -2322,8 +2243,6 @@ export class TelegramClient extends BaseTelegramClient { pinMessage = pinMessage searchGlobal = searchGlobal searchMessages = searchMessages - sendDice = sendDice - sendLocation = sendLocation sendMediaGroup = sendMediaGroup sendMedia = sendMedia sendText = sendText diff --git a/packages/client/src/methods/files/download-iterable.ts b/packages/client/src/methods/files/download-iterable.ts index f1e2e8bb..8c0840e5 100644 --- a/packages/client/src/methods/files/download-iterable.ts +++ b/packages/client/src/methods/files/download-iterable.ts @@ -8,7 +8,7 @@ import { FileDownloadParameters, FileLocation, } from '../../types' -import { fileIdToInputFileLocation } from '@mtcute/file-id' +import { fileIdToInputFileLocation, fileIdToInputWebFileLocation, parseFileId } from '@mtcute/file-id' /** * Download a file and return it as an iterable, which yields file contents @@ -39,30 +39,37 @@ export async function* downloadAsIterable( let dcId = params.dcId let fileSize = params.fileSize - let location = params.location - if (location instanceof FileLocation) { - if (typeof location.location === 'function') { - ;(location as tl.Mutable).location = location.location() + const input = params.location + let location: tl.TypeInputFileLocation | tl.TypeInputWebFileLocation + if (input instanceof FileLocation) { + if (typeof input.location === 'function') { + ;(input as tl.Mutable).location = input.location() } - if (location.location instanceof Buffer) { - yield location.location + if (input.location instanceof Buffer) { + yield input.location return } - if (!dcId) dcId = location.dcId - if (!fileSize) fileSize = location.fileSize - location = location.location as any - } - if (typeof location === 'string') { - location = fileIdToInputFileLocation(location) - } + if (!dcId) dcId = input.dcId + if (!fileSize) fileSize = input.fileSize + location = input.location as any + } else if (typeof input === 'string') { + const parsed = parseFileId(input) + if (parsed.location._ === 'web') { + location = fileIdToInputWebFileLocation(parsed) + } else { + location = fileIdToInputFileLocation(parsed) + } + } else location = input + + const isWeb = tl.isAnyInputWebFileLocation(location) // we will receive a FileMigrateError in case this is invalid if (!dcId) dcId = this._primaryDc.id const chunkSize = partSizeKb * 1024 - const limit = + let limit = params.limit ?? (fileSize ? // derive limit from chunk size, file size and offset @@ -77,13 +84,13 @@ export async function* downloadAsIterable( } const requestCurrent = async (): Promise => { - let result: tl.RpcCallReturn['upload.getFile'] + let result: tl.RpcCallReturn['upload.getFile'] | tl.RpcCallReturn['upload.getWebFile'] try { result = await connection.sendForResult({ - _: 'upload.getFile', - location: location as tl.TypeInputFileLocation, + _: isWeb ? 'upload.getWebFile' : 'upload.getFile', + location: location as any, offset, - limit: chunkSize, + limit: chunkSize }) } catch (e) { if (e instanceof FileMigrateError) { @@ -94,18 +101,25 @@ export async function* downloadAsIterable( } return requestCurrent() } else if (e instanceof FilerefUpgradeNeededError) { - // todo: implement once messages api is ready + // todo: implement someday // see: https://github.com/LonamiWebs/Telethon/blob/0e8bd8248cc649637b7c392616887c50986427a0/telethon/client/downloads.py#L99 throw new MtCuteUnsupportedError('File ref expired!') } else throw e } if (result._ === 'upload.fileCdnRedirect') { + // we shouldnt receive them since cdnSupported is not set in the getFile request. + // also, i couldnt find any media that would be downloaded from cdn, so even if + // i implemented that, i wouldnt be able to test that, so :shrug: throw new MtCuteUnsupportedError( 'Received CDN redirect, which is not supported (yet)' ) } + if (result._ === 'upload.webFile' && result.size && limit === Infinity) { + limit = result.size + } + return result.bytes } diff --git a/packages/client/src/methods/files/normalize-input-media.ts b/packages/client/src/methods/files/normalize-input-media.ts index c38a7ec1..f9e6e798 100644 --- a/packages/client/src/methods/files/normalize-input-media.ts +++ b/packages/client/src/methods/files/normalize-input-media.ts @@ -14,6 +14,8 @@ import { } from '@mtcute/file-id' import { extractFileName } from '../../utils/file-utils' import { assertTypeIs } from '../../utils/type-assertion' +import bigInt from 'big-integer' +import { normalizeDate } from '../../utils/misc-utils' /** * Normalize an {@link InputMediaLike} to `InputMedia`, @@ -25,6 +27,7 @@ export async function _normalizeInputMedia( this: TelegramClient, media: InputMediaLike, params: { + parseMode?: string | null progressCallback?: (uploaded: number, total: number) => void }, uploadMedia = false @@ -35,6 +38,160 @@ export async function _normalizeInputMedia( if (tl.isAnyInputMedia(media)) return media + if (media.type === 'venue') { + return { + _: 'inputMediaVenue', + geoPoint: { + _: 'inputGeoPoint', + lat: media.latitude, + long: media.longitude, + }, + title: media.title, + address: media.address, + provider: media.source?.provider ?? '', + venueId: media.source?.id ?? '', + venueType: media.source?.type ?? '', + } + } + + if (media.type === 'geo') { + return { + _: 'inputMediaGeoPoint', + geoPoint: { + _: 'inputGeoPoint', + lat: media.latitude, + long: media.longitude, + }, + } + } + + if (media.type === 'geo_live') { + return { + _: 'inputMediaGeoLive', + geoPoint: { + _: 'inputGeoPoint', + lat: media.latitude, + long: media.longitude, + }, + heading: media.heading, + period: media.period, + proximityNotificationRadius: media.proximityNotificationRadius, + } + } + + if (media.type === 'dice') { + return { + _: 'inputMediaDice', + emoticon: media.emoji, + } + } + + if (media.type === 'contact') { + return { + _: 'inputMediaContact', + phoneNumber: media.phone, + firstName: media.firstName, + lastName: media.lastName ?? '', + vcard: media.vcard ?? '', + } + } + + if (media.type === 'game') { + return { + _: 'inputMediaGame', + id: + typeof media.game === 'string' + ? { + _: 'inputGameShortName', + botId: { _: 'inputUserSelf' }, + shortName: media.game, + } + : media.game, + } + } + + if (media.type === 'invoice') { + return { + _: 'inputMediaInvoice', + title: media.title, + description: media.description, + photo: + typeof media.photo === 'string' + ? { + _: 'inputWebDocument', + url: media.photo, + mimeType: 'image/jpeg', + size: 0, + attributes: [], + } + : media.photo, + invoice: media.invoice, + payload: media.payload, + provider: media.token, + providerData: { + _: 'dataJSON', + data: JSON.stringify(media.providerData), + }, + startParam: media.startParam, + } + } + + if (media.type === 'poll' || media.type === 'quiz') { + const answers: tl.TypePollAnswer[] = media.answers.map((ans, idx) => { + if (typeof ans === 'string') { + return { + _: 'pollAnswer', + text: ans, + option: Buffer.from([idx]), + } + } + + return ans + }) + + let correct: Buffer[] | undefined = undefined + let solution: string | undefined = undefined + let solutionEntities: tl.TypeMessageEntity[] | undefined = undefined + + if (media.type === 'quiz') { + let input = media.correct + if (!Array.isArray(input)) input = [input] + correct = input.map((it) => { + if (typeof it === 'number') { + return answers[it].option + } + + return it + }) + + if (media.solution) { + ;[solution, solutionEntities] = await this._parseEntities( + media.solution, + params.parseMode, + media.solutionEntities + ) + } + } + + return { + _: 'inputMediaPoll', + poll: { + _: 'poll', + id: bigInt.zero, + publicVoters: media.public, + multipleChoice: media.multiple, + quiz: media.type === 'quiz', + question: media.question, + answers, + closePeriod: media.closePeriod, + closeDate: normalizeDate(media.closeDate) + }, + correctAnswers: correct, + solution, + solutionEntities + } + } + let inputFile: tl.TypeInputFile | undefined = undefined let thumb: tl.TypeInputFile | undefined = undefined let mime = 'application/octet-stream' @@ -56,18 +213,29 @@ export async function _normalizeInputMedia( mime = uploaded.mime } - const uploadMediaIfNeeded = async (inputMedia: tl.TypeInputMedia, photo: boolean): Promise => { + const uploadMediaIfNeeded = async ( + inputMedia: tl.TypeInputMedia, + photo: boolean + ): Promise => { if (!uploadMedia) return inputMedia const res = await this.call({ _: 'messages.uploadMedia', peer: { _: 'inputPeerSelf' }, - media: inputMedia + media: inputMedia, }) if (photo) { - assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res, 'messageMediaPhoto') - assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res.photo!, 'photo') + assertTypeIs( + 'normalizeInputMedia (@ messages.uploadMedia)', + res, + 'messageMediaPhoto' + ) + assertTypeIs( + 'normalizeInputMedia (@ messages.uploadMedia)', + res.photo!, + 'photo' + ) return { _: 'inputMediaPhoto', @@ -75,13 +243,21 @@ export async function _normalizeInputMedia( _: 'inputPhoto', id: res.photo.id, accessHash: res.photo.accessHash, - fileReference: res.photo.fileReference + fileReference: res.photo.fileReference, }, - ttlSeconds: media.ttlSeconds + ttlSeconds: media.ttlSeconds, } } else { - assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res, 'messageMediaDocument') - assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res.document!, 'document') + assertTypeIs( + 'normalizeInputMedia (@ messages.uploadMedia)', + res, + 'messageMediaDocument' + ) + assertTypeIs( + 'normalizeInputMedia (@ messages.uploadMedia)', + res.document!, + 'document' + ) return { _: 'inputMediaDocument', @@ -89,9 +265,9 @@ export async function _normalizeInputMedia( _: 'inputDocument', id: res.document.id, accessHash: res.document.accessHash, - fileReference: res.document.fileReference + fileReference: res.document.fileReference, }, - ttlSeconds: media.ttlSeconds + ttlSeconds: media.ttlSeconds, } } } @@ -99,13 +275,16 @@ export async function _normalizeInputMedia( const input = media.file if (tdFileId.isFileIdLike(input)) { if (typeof input === 'string' && input.match(/^https?:\/\//)) { - return uploadMediaIfNeeded({ - _: - media.type === 'photo' - ? 'inputMediaPhotoExternal' - : 'inputMediaDocumentExternal', - url: input, - }, media.type === 'photo') + return uploadMediaIfNeeded( + { + _: + media.type === 'photo' + ? 'inputMediaPhotoExternal' + : 'inputMediaDocumentExternal', + url: input, + }, + media.type === 'photo' + ) } else if (typeof input === 'string' && input.match(/^file:/)) { await upload(input.substr(5)) } else { @@ -118,13 +297,16 @@ export async function _normalizeInputMedia( id: fileIdToInputPhoto(parsed), } } else if (parsed.location._ === 'web') { - return uploadMediaIfNeeded({ - _: - parsed.type === tdFileId.FileType.Photo - ? 'inputMediaPhotoExternal' - : 'inputMediaDocumentExternal', - url: parsed.location.url, - }, parsed.type === tdFileId.FileType.Photo) + return uploadMediaIfNeeded( + { + _: + parsed.type === tdFileId.FileType.Photo + ? 'inputMediaPhotoExternal' + : 'inputMediaDocumentExternal', + url: parsed.location.url, + }, + parsed.type === tdFileId.FileType.Photo + ) } else { return { _: 'inputMediaDocument', @@ -146,11 +328,14 @@ export async function _normalizeInputMedia( if (!inputFile) throw new Error('should not happen') if (media.type === 'photo') { - return uploadMediaIfNeeded({ - _: 'inputMediaUploadedPhoto', - file: inputFile, - ttlSeconds: media.ttlSeconds, - }, true) + return uploadMediaIfNeeded( + { + _: 'inputMediaUploadedPhoto', + file: inputFile, + ttlSeconds: media.ttlSeconds, + }, + true + ) } if ('thumb' in media && media.thumb) { @@ -204,14 +389,17 @@ export async function _normalizeInputMedia( }) } - return uploadMediaIfNeeded({ - _: 'inputMediaUploadedDocument', - nosoundVideo: media.type === 'video' && media.isAnimated, - forceFile: media.type === 'document', - file: inputFile, - thumb, - mimeType: mime, - attributes, - ttlSeconds: media.ttlSeconds - }, false) + return uploadMediaIfNeeded( + { + _: 'inputMediaUploadedDocument', + nosoundVideo: media.type === 'video' && media.isAnimated, + forceFile: media.type === 'document', + file: inputFile, + thumb, + mimeType: mime, + attributes, + ttlSeconds: media.ttlSeconds, + }, + false + ) } diff --git a/packages/client/src/methods/messages/edit-inline-message.ts b/packages/client/src/methods/messages/edit-inline-message.ts index 9893c7e3..090e3de2 100644 --- a/packages/client/src/methods/messages/edit-inline-message.ts +++ b/packages/client/src/methods/messages/edit-inline-message.ts @@ -97,7 +97,10 @@ export async function editInlineMessage( if (params.media) { media = await this._normalizeInputMedia(params.media, params, true) - if ('caption' in params.media) { + + // if there's no caption in input media (i.e. not present or undefined), + // user wants to keep current caption, thus `content` needs to stay `undefined` + if ('caption' in params.media && params.media.caption !== undefined) { ;[content, entities] = await this._parseEntities( params.media.caption, params.parseMode, diff --git a/packages/client/src/methods/messages/edit-message.ts b/packages/client/src/methods/messages/edit-message.ts index 61e8c64e..665a8491 100644 --- a/packages/client/src/methods/messages/edit-message.ts +++ b/packages/client/src/methods/messages/edit-message.ts @@ -84,11 +84,16 @@ export async function editMessage( if (params.media) { media = await this._normalizeInputMedia(params.media, params) - ;[content, entities] = await this._parseEntities( - params.media.caption, - params.parseMode, - params.media.entities - ) + + // if there's no caption in input media (i.e. not present or undefined), + // user wants to keep current caption, thus `content` needs to stay `undefined` + if ('caption' in params.media && params.media.caption !== undefined) { + ;[content, entities] = await this._parseEntities( + params.media.caption, + params.parseMode, + params.media.entities + ) + } } else { ;[content, entities] = await this._parseEntities( params.text, @@ -105,7 +110,7 @@ export async function editMessage( replyMarkup: BotKeyboard._convertToTl(params.replyMarkup), message: content, entities, - media + media, }) return this._findMessageInUpdate(res, true) as any diff --git a/packages/client/src/methods/messages/send-dice.ts b/packages/client/src/methods/messages/send-dice.ts deleted file mode 100644 index 7b87c04d..00000000 --- a/packages/client/src/methods/messages/send-dice.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { TelegramClient } from '../../client' -import { BotKeyboard, InputPeerLike, Message, ReplyMarkup } from '../../types' -import { normalizeDate, randomUlong } from '../../utils/misc-utils' -import { normalizeToInputPeer } from '../../utils/peer-utils' - -/** - * Send an animated dice with a random value. - * - * For convenience, known dice emojis are available - * as static members of {@link Dice}. - * - * Note that dice result value is generated randomly on the server, - * you can't influence it in any way! - * - * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` - * @param emoji Emoji representing a dice - * @param params Additional sending parameters - * @link Dice - * @internal - */ -export async function sendDice( - this: TelegramClient, - chatId: InputPeerLike, - emoji: string, - params?: { - /** - * Message to reply to. Either a message object or message ID. - */ - replyTo?: number | Message - - /** - * 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 - } -): Promise { - if (!params) params = {} - - const peer = normalizeToInputPeer(await this.resolvePeer(chatId)) - const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) - - const res = await this.call({ - _: 'messages.sendMedia', - peer, - media: { - _: 'inputMediaDice', - emoticon: emoji, - }, - silent: params.silent, - replyToMsgId: params.replyTo - ? typeof params.replyTo === 'number' - ? params.replyTo - : params.replyTo.id - : undefined, - randomId: randomUlong(), - scheduleDate: normalizeDate(params.schedule), - replyMarkup, - message: '', - }) - - return this._findMessageInUpdate(res) -} diff --git a/packages/client/src/methods/messages/send-location.ts b/packages/client/src/methods/messages/send-location.ts deleted file mode 100644 index 35649613..00000000 --- a/packages/client/src/methods/messages/send-location.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { BotKeyboard, InputPeerLike, Message, ReplyMarkup } from '../../types' -import { TelegramClient } from '../../client' -import { normalizeToInputPeer } from '../../utils/peer-utils' -import { normalizeDate, randomUlong } from '../../utils/misc-utils' - -/** - * Send a static geo location. - * - * @param chatId ID of the chat, its username, phone or `"me"` or `"self"` - * @param latitude Latitude of the location - * @param longitude Longitude of the location - * @param params Additional sending parameters - * @internal - */ -export async function sendLocation( - this: TelegramClient, - chatId: InputPeerLike, - latitude: number, - longitude: number, - params?: { - /** - * Message to reply to. Either a message object or message ID. - */ - replyTo?: number | Message - - /** - * 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 - } -): Promise { - if (!params) params = {} - - const peer = normalizeToInputPeer(await this.resolvePeer(chatId)) - const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) - - const res = await this.call({ - _: 'messages.sendMedia', - peer, - media: { - _: 'inputMediaGeoPoint', - geoPoint: { - _: 'inputGeoPoint', - lat: latitude, - long: longitude - } - }, - silent: params.silent, - replyToMsgId: params.replyTo - ? typeof params.replyTo === 'number' - ? params.replyTo - : params.replyTo.id - : undefined, - randomId: randomUlong(), - scheduleDate: normalizeDate(params.schedule), - replyMarkup, - message: '', - }) - - return this._findMessageInUpdate(res) -} diff --git a/packages/client/src/methods/messages/send-media-group.ts b/packages/client/src/methods/messages/send-media-group.ts index ea48748c..8fd6c1bf 100644 --- a/packages/client/src/methods/messages/send-media-group.ts +++ b/packages/client/src/methods/messages/send-media-group.ts @@ -63,7 +63,11 @@ export async function sendMediaGroup( * @param uploaded Number of bytes already uploaded * @param total Total file size */ - progressCallback?: (index: number, uploaded: number, total: number) => void + progressCallback?: ( + index: number, + uploaded: number, + total: number + ) => void /** * Whether to clear draft after sending this message. @@ -83,13 +87,16 @@ export async function sendMediaGroup( for (let i = 0; i < medias.length; i++) { const media = medias[i] const inputMedia = await this._normalizeInputMedia(media, { - progressCallback: params.progressCallback?.bind(null, i) + progressCallback: params.progressCallback?.bind(null, i), }) const [message, entities] = await this._parseEntities( - media.caption, + // some types dont have `caption` field, and ts warns us, + // but since it's JS, they'll just be `undefined` and properly + // handled by _parseEntities method + (media as any).caption, params.parseMode, - media.entities + (media as any).entities ) multiMedia.push({ @@ -97,7 +104,7 @@ export async function sendMediaGroup( randomId: randomUlong(), media: inputMedia, message, - entities + entities, }) } diff --git a/packages/client/src/methods/messages/send-media.ts b/packages/client/src/methods/messages/send-media.ts index 46380a5c..ded25af5 100644 --- a/packages/client/src/methods/messages/send-media.ts +++ b/packages/client/src/methods/messages/send-media.ts @@ -86,9 +86,12 @@ export async function sendMedia( const inputMedia = await this._normalizeInputMedia(media, params) const [message, entities] = await this._parseEntities( - media.caption, + // some types dont have `caption` field, and ts warns us, + // but since it's JS, they'll just be `undefined` and properly + // handled by _parseEntities method + (media as any).caption, params.parseMode, - media.entities + (media as any).entities ) const peer = normalizeToInputPeer(await this.resolvePeer(chatId)) diff --git a/packages/client/src/types/bots/input/input-inline-message.ts b/packages/client/src/types/bots/input/input-inline-message.ts index 2070ba1f..8ad9967f 100644 --- a/packages/client/src/types/bots/input/input-inline-message.ts +++ b/packages/client/src/types/bots/input/input-inline-message.ts @@ -1,6 +1,12 @@ import { tl } from '@mtcute/tl' import { BotKeyboard, ReplyMarkup } from '../keyboards' import { TelegramClient } from '../../../client' +import { + InputMediaGeo, + InputMediaGeoLive, + InputMediaVenue, + Venue, +} from '../../media' /** * Inline message containing only text @@ -57,37 +63,17 @@ export interface InputInlineMessageMedia { /** * Inline message containing a geolocation */ -export interface InputInlineMessageGeo { - type: 'geo' - +export interface InputInlineMessageGeo extends InputMediaGeo { /** - * Latitude of the geolocation + * Message's reply markup */ - latitude: number - - /** - * Longitude of the geolocation - */ - longitude: number - - /** - * For live locations, direction in which the location - * moves, in degrees (1-360) - */ - heading?: number - - /** - * For live locations, period for which this live location - * will be updated - */ - period?: number - - /** - * For live locations, a maximum distance to another - * chat member for proximity alerts, in meters (0-100000) - */ - proximityNotificationRadius?: number + replyMarkup?: ReplyMarkup +} +/** + * Inline message containing a live geolocation + */ +export interface InputInlineMessageGeoLive extends InputMediaGeoLive { /** * Message's reply markup */ @@ -97,54 +83,7 @@ export interface InputInlineMessageGeo { /** * Inline message containing a venue */ -export interface InputInlineMessageVenue { - type: 'venue' - - /** - * Latitude of the geolocation - */ - latitude: number - - /** - * Longitude of the geolocation - */ - longitude: number - - /** - * Venue name - */ - title: string - - /** - * Venue address - */ - address: string - - /** - * When available, source from where this venue was acquired - */ - source?: { - /** - * Provider name (`foursquare` or `gplaces` for Google Places) - */ - provider?: 'foursquare' | 'gplaces' - - /** - * Venue ID in the provider's DB - */ - id: string - - /** - * Venue type in the provider's DB - * - * - [Supported types for Foursquare](https://developer.foursquare.com/docs/build-with-foursquare/categories/) - * (use names, lowercase them, replace spaces and " & " with `_` (underscore) and remove other symbols, - * and use `/` (slash) as hierarchy separator) - * - [Supported types for Google Places](https://developers.google.com/places/web-service/supported_types) - */ - type: string - } - +export interface InputInlineMessageVenue extends InputMediaVenue { /** * Message's reply markup */ @@ -167,51 +106,62 @@ export type InputInlineMessage = | InputInlineMessageText | InputInlineMessageMedia | InputInlineMessageGeo + | InputInlineMessageGeoLive | InputInlineMessageVenue | InputInlineMessageGame export namespace BotInlineMessage { - export function text ( + export function text( text: string, - params?: Omit, + params?: Omit ): InputInlineMessageText { return { type: 'text', text, - ...( - params || {} - ), + ...(params || {}), } } - export function media ( - params?: Omit, + export function media( + params?: Omit ): InputInlineMessageMedia { return { type: 'media', - ...( - params || {} - ), + ...(params || {}), } } - export function geo ( + export function geo( latitude: number, longitude: number, - params?: Omit, + params?: Omit ): InputInlineMessageGeo { return { type: 'geo', latitude, longitude, - ...( - params || {} - ), + ...(params || {}), } } - export function venue ( - params: Omit, + export function geoLive( + latitude: number, + longitude: number, + params?: Omit< + InputInlineMessageGeoLive, + 'type' | 'latitude' | 'longitude' + > + ): InputInlineMessageGeoLive { + return { + type: 'geo_live', + latitude, + longitude, + ...(params || {}), + } + } + + export function venue( + params: Omit ): InputInlineMessageVenue { return { type: 'venue', @@ -219,8 +169,8 @@ export namespace BotInlineMessage { } } - export function game ( - params: Omit, + export function game( + params: Omit ): InputInlineMessageGame { return { type: 'game', @@ -228,45 +178,55 @@ export namespace BotInlineMessage { } } - export async function _convertToTl ( + export async function _convertToTl( client: TelegramClient, obj: InputInlineMessage, - parseMode?: string | null, + parseMode?: string | null ): Promise { if (obj.type === 'text') { - const [message, entities] = await client['_parseEntities'](obj.text, parseMode, obj.entities) + const [message, entities] = await client['_parseEntities']( + obj.text, + parseMode, + obj.entities + ) return { _: 'inputBotInlineMessageText', message, entities, - replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) + replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup), } } if (obj.type === 'media') { - const [message, entities] = await client['_parseEntities'](obj.text, parseMode, obj.entities) + const [message, entities] = await client['_parseEntities']( + obj.text, + parseMode, + obj.entities + ) return { _: 'inputBotInlineMessageMediaAuto', message, entities, - replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) + replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup), } } - if (obj.type === 'geo') { + if (obj.type === 'geo' || obj.type === 'geo_live') { return { _: 'inputBotInlineMessageMediaGeo', geoPoint: { _: 'inputGeoPoint', lat: obj.latitude, - long: obj.longitude + long: obj.longitude, }, - heading: obj.heading, - period: obj.period, - proximityNotificationRadius: obj.proximityNotificationRadius, - replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) + // fields will be `undefined` if this is a `geo` + heading: (obj as InputMediaGeoLive).heading, + period: (obj as InputMediaGeoLive).period, + proximityNotificationRadius: (obj as InputMediaGeoLive) + .proximityNotificationRadius, + replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup), } } @@ -276,21 +236,21 @@ export namespace BotInlineMessage { geoPoint: { _: 'inputGeoPoint', lat: obj.latitude, - long: obj.longitude + long: obj.longitude, }, title: obj.title, address: obj.address, provider: obj.source?.provider ?? '', venueId: obj.source?.id ?? '', venueType: obj.source?.type ?? '', - replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) + replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup), } } if (obj.type === 'game') { return { _: 'inputBotInlineMessageGame', - replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) + replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup), } } diff --git a/packages/client/src/types/files/file-location.ts b/packages/client/src/types/files/file-location.ts index 713ca788..6b68883f 100644 --- a/packages/client/src/types/files/file-location.ts +++ b/packages/client/src/types/files/file-location.ts @@ -27,8 +27,9 @@ export class FileLocation { */ readonly location: | tl.TypeInputFileLocation + | tl.TypeInputWebFileLocation | Buffer - | (() => tl.TypeInputFileLocation | Buffer) + | (() => tl.TypeInputFileLocation | tl.TypeInputWebFileLocation | Buffer) /** * File size in bytes, when available @@ -44,8 +45,9 @@ export class FileLocation { client: TelegramClient, location: | tl.TypeInputFileLocation + | tl.TypeInputWebFileLocation | Buffer - | (() => tl.TypeInputFileLocation | Buffer), + | (() => tl.TypeInputFileLocation | tl.TypeInputWebFileLocation | Buffer), fileSize?: number, dcId?: number ) { @@ -82,7 +84,7 @@ export class FileLocation { * in chunks of a given size. Order of the chunks is guaranteed to be * consecutive. * - * Shorthand for `client.downloadAsStream({ location: this })` + * Shorthand for `client.downloadAsIterable({ location: this })` * * @link TelegramClient.downloadAsIterable */ diff --git a/packages/client/src/types/files/utils.ts b/packages/client/src/types/files/utils.ts index 7574b62a..c216120d 100644 --- a/packages/client/src/types/files/utils.ts +++ b/packages/client/src/types/files/utils.ts @@ -52,7 +52,7 @@ export interface FileDownloadParameters { * File location which should be downloaded. * You can also provide TDLib and Bot API compatible File ID */ - location: tl.TypeInputFileLocation | FileLocation | string + location: tl.TypeInputFileLocation | tl.TypeInputWebFileLocation | FileLocation | string /** * Total file size, if known. diff --git a/packages/client/src/types/files/web-document.ts b/packages/client/src/types/files/web-document.ts new file mode 100644 index 00000000..e1835de8 --- /dev/null +++ b/packages/client/src/types/files/web-document.ts @@ -0,0 +1,67 @@ +import { TelegramClient } from '../../client' +import { tl } from '@mtcute/tl' +import { FileLocation } from './file-location' +import { MtCuteArgumentError } from '../errors' +import { makeInspectable } from '../utils' + +const STUB_LOCATION = () => { + throw new MtCuteArgumentError( + 'This web document is not downloadable through Telegram' + ) +} + +/** + * An external web document, that is not + * stored on Telegram severs, and is available + * by a HTTP(s) url. + * + * > **Note**: not all web documents are downloadable + * > through Telegram. Media files usually are, + * > and web pages (i.e. `mimeType = text/html`) usually aren't. + * > To be sure, check `isDownloadable` property. + */ +export class WebDocument extends FileLocation { + readonly raw: tl.TypeWebDocument + + constructor(client: TelegramClient, raw: tl.TypeWebDocument) { + super( + client, + raw._ === 'webDocument' + ? { + _: 'inputWebFileLocation', + url: raw.url, + accessHash: raw.accessHash, + } + : STUB_LOCATION, + raw.size + ) + this.raw = raw + } + + /** + * URL to the file + */ + get url(): string { + return this.raw.url + } + + /** + * MIME type of the file + */ + get mimeType(): string { + return this.raw.mimeType + } + + /** + * Whether this file can be downloaded through Telegram. + * + * If `false`, you should use {@link url} to manually + * fetch data via HTTP(s), and trying to use `download*` methods + * will result in an error + */ + get isDownloadable(): boolean { + return this.raw._ === 'webDocument' + } +} + +makeInspectable(WebDocument) diff --git a/packages/client/src/types/media/index.ts b/packages/client/src/types/media/index.ts index 5a58afd4..8d7cb053 100644 --- a/packages/client/src/types/media/index.ts +++ b/packages/client/src/types/media/index.ts @@ -9,3 +9,4 @@ export * from './location' export * from './voice' export * from './sticker' export * from './input-media' +export * from './venue' diff --git a/packages/client/src/types/media/input-media.ts b/packages/client/src/types/media/input-media.ts index 43f3f4ab..3d0d4d92 100644 --- a/packages/client/src/types/media/input-media.ts +++ b/packages/client/src/types/media/input-media.ts @@ -1,5 +1,7 @@ import { InputFileLike } from '../files' import { tl } from '@mtcute/tl' +import { Venue } from './venue' +import { MaybeArray } from '@mtcute/core' interface BaseInputMedia { /** @@ -233,6 +235,303 @@ export interface InputMediaVideo extends BaseInputMedia { isRound?: boolean } +/** + * A geolocation to be sent + */ +export interface InputMediaGeo { + type: 'geo' + + /** + * Latitude of the geolocation + */ + latitude: number + + /** + * Longitude of the geolocation + */ + longitude: number + + /** + * The estimated horizontal accuracy of the + * geolocation, in meters (0-1500) + */ + accuracy?: number +} + +/** + * A live geolocation to be sent + */ +export interface InputMediaGeoLive extends Omit { + type: 'geo_live' + + /** + * Direction in which the location moves, in degrees (1-360) + */ + heading?: number + + /** + * Validity period of the live location + */ + period?: number + + /** + * Maximum distance to another chat member for proximity + * alerts, in meters (0-100000) + */ + proximityNotificationRadius?: number +} + +/** + * An animated dice with a random value to be sent + * + * For convenience, known dice emojis are available + * as static members of {@link Dice}. + * + * Note that dice result value is generated randomly on the server, + * you can't influence it in any way! + */ +export interface InputMediaDice { + type: 'dice' + + /** + * Emoji representing a dice + */ + emoji: string +} + +/** + * A venue to be sent + */ +export interface InputMediaVenue { + type: 'venue' + + /** + * Latitude of the geolocation + */ + latitude: number + + /** + * Longitude of the geolocation + */ + longitude: number + + /** + * Venue name + */ + title: string + + /** + * Venue address + */ + address: string + + /** + * When available, source from where this venue was acquired + */ + source?: Venue.VenueSource +} + +/** + * A contact to be sent + */ +export interface InputMediaContact { + type: 'contact' + + /** + * Contact's phone number + */ + phone: string + + /** + * Contact's first name + */ + firstName: string + + /** + * Contact's last name + */ + lastName?: string + + /** + * Additional data about the contact + * as a vCard (0-2048 bytes) + */ + vcard?: string +} + +/** + * A game to be sent + */ +export interface InputMediaGame { + type: 'game' + + /** + * Game's short name, or a TL object with an input game + */ + game: string | tl.TypeInputGame +} + +/** + * An invoice to be sent (see https://core.telegram.org/bots/payments) + */ +export interface InputMediaInvoice { + type: 'invoice' + + /** + * Product name (1-32 chars) + */ + title: string + + /** + * Product description (1-255 chars) + */ + description: string + + /** + * The invoice itself + */ + invoice: tl.TypeInvoice + + /** + * Bot-defined invoice payload (1-128 bytes). + * + * Will not be displayed to the user and can be used + * for internal processes + */ + payload: Buffer + + /** + * Payments provider token, obtained from + * [@BotFather](//t.me/botfather) + */ + token: string + + /** + * Data about the invoice as a plain JS object, which + * will be shared with the payment provider. A detailed + * description of required fields should be provided by + * the payment provider. + */ + providerData: any + + /** + * Start parameter for the bot + */ + startParam: string + + /** + * Product photo. Can be a photo of the goods or a marketing image for a service. + * + * Can be a URL, or a TL object with input web document + */ + photo?: string | tl.TypeInputWebDocument +} + +/** + * A simple poll to be sent + */ +export interface InputMediaPoll { + type: 'poll' + + /** + * Question of the poll (1-255 chars for users, 1-300 chars for bots) + */ + question: string + + /** + * Answers of the poll. + * + * You can either provide a string, or a + * TL object representing an answer. + * Strings will be transformed to TL + * objects, with a single=byte incrementing + * `option` value. + */ + answers: (string | tl.TypePollAnswer)[] + + /** + * Whether this is a public poll + * (i.e. users who have voted are visible to everyone) + */ + public?: boolean + + /** + * Whether users can select multiple answers + * as an answer + */ + multiple?: boolean + + /** + * Amount of time in seconds the poll will be active after creation (5-600). + * + * Can't be used together with `closeDate`. + */ + closePeriod?: number + + /** + * Point in time when the poll will be automatically closed. + * + * Must be at least 5 and no more than 600 seconds in the future, + * can't be used together with `closePeriod`. + * + * When `number` is used, UNIX time in ms is expected + */ + closeDate?: number | Date +} + +/** + * A quiz to be sent. + * + * Quiz is an extended version of a poll, but quizzes have + * correct answers, and votes can't be retracted from them + */ +export interface InputMediaQuiz extends Omit { + type: 'quiz' + + /** + * Correct answer ID(s) or index(es). + * + * > **Note**: even though quizzes can actually + * > only have exactly one correct answer, + * > the API itself has the possibility to pass + * > multiple or zero correct answers, + * > but that would result in `QUIZ_CORRECT_ANSWERS_TOO_MUCH` + * > and `QUIZ_CORRECT_ANSWERS_EMPTY` errors respectively. + * > + * > But since the API has that option, we also provide it, + * > maybe to future-proof this :shrug: + */ + correct: MaybeArray + + /** + * Explanation of the quiz solution + */ + solution?: string + + /** + * Format entities for `solution`. + * If used, parse mode is ignored. + */ + solutionEntities?: tl.TypeMessageEntity[] +} + +/** + * Input media that can have a caption. + * + * Note that meta-fields (like `duration`) are only + * applicable if `file` is {@link UploadFileLike}, + * otherwise they are ignored. + * + * A subset of {@link InputMediaLike} + */ +export type InputMediaWithCaption = + | InputMediaAudio + | InputMediaVoice + | InputMediaDocument + | InputMediaPhoto + | InputMediaVideo + | InputMediaAuto + /** * Input media that can be sent somewhere. * @@ -243,17 +542,24 @@ export interface InputMediaVideo extends BaseInputMedia { * @link InputMedia */ export type InputMediaLike = - | InputMediaAudio - | InputMediaVoice - | InputMediaDocument - | InputMediaPhoto - | InputMediaVideo - | InputMediaAuto + | InputMediaWithCaption | InputMediaSticker + | InputMediaVenue + | InputMediaGeo + | InputMediaGeoLive + | InputMediaDice + | InputMediaContact + | InputMediaGame + | InputMediaInvoice + | InputMediaPoll + | InputMediaQuiz | tl.TypeInputMedia export namespace InputMedia { - type OmitTypeAndFile = Omit + type OmitTypeAndFile< + T extends InputMediaLike, + K extends keyof T = never + > = Omit /** * Create an animation to be sent @@ -354,6 +660,121 @@ export namespace InputMedia { } } + /** + * Create a venue to be sent + */ + export function venue( + params: OmitTypeAndFile + ): InputMediaVenue { + return { + type: 'venue', + ...params, + } + } + + /** + * Create a geolocation to be sent + */ + export function geo( + latitude: number, + longitude: number, + params?: OmitTypeAndFile + ): InputMediaGeo { + return { + type: 'geo', + latitude, + longitude, + ...(params || {}), + } + } + + /** + * Create a live geolocation to be sent + */ + export function geoLive( + latitude: number, + longitude: number, + params?: OmitTypeAndFile + ): InputMediaGeoLive { + return { + type: 'geo_live', + latitude, + longitude, + ...(params || {}), + } + } + + /** + * Create a dice to be sent + * + * For convenience, known dice emojis are available + * as static members of {@link Dice}. + */ + export function dice(emoji: string): InputMediaDice { + return { + type: 'dice', + emoji, + } + } + + /** + * Create a contact to be sent + */ + export function contact( + params: OmitTypeAndFile + ): InputMediaContact { + return { + type: 'contact', + ...params, + } + } + + /** + * Create a game to be sent + */ + export function game(game: string | tl.TypeInputGame): InputMediaGame { + return { + type: 'game', + game, + } + } + + /** + * Create an invoice to be sent + */ + export function invoice( + params: OmitTypeAndFile + ): InputMediaInvoice { + return { + type: 'invoice', + ...params, + } + } + + /** + * Create a poll to be sent + */ + export function poll( + params: OmitTypeAndFile + ): InputMediaPoll { + return { + type: 'poll', + ...params, + } + } + + /** + * Create a quiz to be sent + */ + export function quiz( + params: OmitTypeAndFile + ): InputMediaQuiz { + return { + type: 'quiz', + ...params, + } + } + /** * Create a document to be sent, which subtype * is inferred automatically by file contents. diff --git a/packages/client/src/types/media/invoice.ts b/packages/client/src/types/media/invoice.ts new file mode 100644 index 00000000..41c93998 --- /dev/null +++ b/packages/client/src/types/media/invoice.ts @@ -0,0 +1,95 @@ +import { tl } from '@mtcute/tl' +import { TelegramClient } from '../../client' +import { makeInspectable } from '../utils' +import { WebDocument } from '../files/web-document' + +/** + * An invoice + */ +export class Invoice { + readonly client: TelegramClient + readonly raw: tl.RawMessageMediaInvoice + + constructor (client: TelegramClient, raw: tl.RawMessageMediaInvoice) { + this.client = client + this.raw = raw + } + + /** + * Whether the shipping address was requested + */ + isShippingAddressRequested(): boolean { + return !!this.raw.shippingAddressRequested + } + + /** + * Whether this is an example (test) invoice + */ + isTest(): boolean { + return !!this.raw.test + } + + /** + * Product name, 1-32 characters + */ + get title(): string { + return this.raw.title + } + + /** + * Product description, 1-255 characters + */ + get description(): string { + return this.raw.description + } + + private _photo?: WebDocument + /** + * URL of the product photo for the invoice + */ + get photo(): WebDocument | null { + if (!this.raw.photo) return null + + if (!this._photo) { + this._photo = new WebDocument(this.client, this.raw.photo) + } + + return this._photo + } + + /** + * Message ID of receipt + */ + get receiptMessageId(): number | null { + return this.raw.receiptMsgId ?? null + } + + /** + * Three-letter ISO 4217 currency code + */ + get currency(): string { + return this.raw.currency + } + + /** + * Total price in the smallest units of the currency + * (integer, not float/double). For example, for a price + * of `US$ 1.45` `amount = 145`. + * + * See the exp parameter in [currencies.json](https://core.telegram.org/bots/payments/currencies.json), + * it shows the number of digits past the decimal point + * for each currency (2 for the majority of currencies). + */ + get amount(): tl.Long { + return this.raw.totalAmount + } + + /** + * Unique bot deep-linking parameter that can be used to generate this invoice + */ + get startParam(): string { + return this.raw.startParam + } +} + +makeInspectable(Invoice) diff --git a/packages/client/src/types/media/location.ts b/packages/client/src/types/media/location.ts index 90c57f47..a6307133 100644 --- a/packages/client/src/types/media/location.ts +++ b/packages/client/src/types/media/location.ts @@ -1,13 +1,17 @@ import { makeInspectable } from '../utils' import { tl } from '@mtcute/tl' +import { FileLocation } from '../files' +import { TelegramClient } from '../../client' /** * A point on the map */ export class Location { + readonly client: TelegramClient readonly geo: tl.RawGeoPoint - constructor(geo: tl.RawGeoPoint) { + constructor(client: TelegramClient, geo: tl.RawGeoPoint) { + this.client = client this.geo = geo } @@ -31,13 +35,62 @@ export class Location { get radius(): number { return this.geo.accuracyRadius ?? 0 } + + /** + * Create {@link FileLocation} containing + * server-generated image with the map preview + */ + preview(params: { + /** + * Map width in pixels before applying scale (16-1024) + * + * Defaults to `128` + */ + width?: number + + /** + * Map height in pixels before applying scale (16-1024) + * + * Defaults to `128` + */ + height?: number + + /** + * Map zoom level (13-20) + * + * Defaults to `15` + */ + zoom?: number + + /** + * Map scale (1-3) + * + * Defaults to `1` + */ + scale?: number + } = {}): FileLocation { + return new FileLocation(this.client, { + _: 'inputWebFileGeoPointLocation', + geoPoint: { + _: 'inputGeoPoint', + lat: this.geo.lat, + long: this.geo.long, + accuracyRadius: this.geo.accuracyRadius + }, + accessHash: this.geo.accessHash, + w: params.width ?? 128, + h: params.height ?? 128, + zoom: params.zoom ?? 15, + scale: params.scale ?? 1, + }) + } } export class LiveLocation extends Location { readonly live: tl.RawMessageMediaGeoLive - constructor(live: tl.RawMessageMediaGeoLive) { - super(live.geo as tl.RawGeoPoint) + constructor(client: TelegramClient, live: tl.RawMessageMediaGeoLive) { + super(client, live.geo as tl.RawGeoPoint) this.live = live } diff --git a/packages/client/src/types/media/poll.ts b/packages/client/src/types/media/poll.ts new file mode 100644 index 00000000..aecc12eb --- /dev/null +++ b/packages/client/src/types/media/poll.ts @@ -0,0 +1,185 @@ +import { makeInspectable } from '../utils' +import { tl } from '@mtcute/tl' +import { TelegramClient } from '../../client' +import { MessageEntity } from '../messages' + +export namespace Poll { + export interface PollAnswer { + /** + * Answer text + */ + text: string + + /** + * Answer data, to be passed to + * {@link TelegramClient.votePoll} + */ + data: Buffer + + /** + * Number of people who has chosen this result. + * If not available (i.e. not voted yet), defaults to `0` + */ + voters: number + + /** + * Whether this answer was chosen by the current user + */ + chosen: boolean + + /** + * Whether this answer is correct (for quizzes). + * Not available before choosing an answer, and defaults to `false` + */ + correct: boolean + } +} + +export class Poll { + readonly client: TelegramClient + readonly raw: tl.TypePoll + readonly results?: tl.TypePollResults + + readonly _users: Record + + constructor( + client: TelegramClient, + raw: tl.TypePoll, + users: Record, + results?: tl.TypePollResults + ) { + this.client = client + this.raw = raw + this._users = users + this.results = results + } + + /** + * Unique identifier of the poll + */ + get id(): tl.Long { + return this.raw.id + } + + /** + * Poll question + */ + get question(): string { + return this.raw.question + } + + private _answers?: Poll.PollAnswer[] + /** + * List of answers in this poll + */ + get answers(): Poll.PollAnswer[] { + if (!this._answers) { + const results = this.results?.results + + this._answers = this.raw.answers.map((ans, idx) => { + if (results) { + const res = results[idx] + return { + text: ans.text, + data: ans.option, + voters: res.voters, + chosen: !!res.chosen, + correct: !!res.correct + } + } else { + return { + text: ans.text, + data: ans.option, + voters: 0, + chosen: false, + correct: false + } + } + }) + } + + return this._answers + } + + /** + * Total number of voters in this poll, if available + */ + get voters(): number { + return this.results?.totalVoters ?? 0 + } + + /** + * Whether this poll is closed, i.e. does not + * accept votes anymore + */ + get isClosed(): boolean { + return !!this.raw.closed + } + + /** + * Whether this poll is public, i.e. you + * list of voters is publicly available + */ + get isPublic(): boolean { + return !!this.raw.publicVoters + } + + /** + * Whether this is a quiz + */ + get isQuiz(): boolean { + return !!this.raw.quiz + } + + /** + * Whether this poll accepts multiple answers + */ + get isMultiple(): boolean { + return !!this.raw.multipleChoice + } + + /** + * Solution for the quiz, only available + * in case you have already answered + */ + get solution(): string | null { + return this.results?.solution ?? null + } + + private _entities?: MessageEntity[] + /** + * Format entities for {@link solution}, only available + * in case you have already answered + */ + get solutionEntities(): MessageEntity[] | null { + if (!this.results) return null + + if (!this._entities) { + this._entities = [] + if (this.results.solutionEntities?.length) { + for (const ent of this.results.solutionEntities) { + const parsed = MessageEntity._parse(ent) + if (parsed) this._entities.push(parsed) + } + } + } + + return this._entities + } + + /** + * Get the solution text formatted with a given parse mode. + * Returns `null` if solution is not available + * + * @param parseMode Parse mode to use (`null` for default) + */ + unparseSolution(parseMode?: string | null): string | null { + if (!this.solution) return null + + return this.client + .getParseMode(parseMode) + .unparse(this.solution, this.solutionEntities!) + } +} + +makeInspectable(Poll) diff --git a/packages/client/src/types/media/venue.ts b/packages/client/src/types/media/venue.ts new file mode 100644 index 00000000..07d84c66 --- /dev/null +++ b/packages/client/src/types/media/venue.ts @@ -0,0 +1,81 @@ +import { tl } from '@mtcute/tl' +import { Location } from './location' +import { assertTypeIs } from '../../utils/type-assertion' +import { makeInspectable } from '../utils' +import { TelegramClient } from '../../client' + +export namespace Venue { + export interface VenueSource { + /** + * Provider name (`foursquare` or `gplaces` for Google Places) + */ + provider?: 'foursquare' | 'gplaces' + + /** + * Venue ID in the provider's DB + */ + id: string + + /** + * Venue type in the provider's DB + * + * - [Supported types for Foursquare](https://developer.foursquare.com/docs/build-with-foursquare/categories/) + * (use names, lowercase them, replace spaces and " & " with `_` (underscore) and remove other symbols, + * and use `/` (slash) as hierarchy separator) + * - [Supported types for Google Places](https://developers.google.com/places/web-service/supported_types) + */ + type: string + } +} + +export class Venue { + readonly client: TelegramClient + readonly raw: tl.RawMessageMediaVenue + + constructor (client: TelegramClient, raw: tl.RawMessageMediaVenue) { + this.client = client + this.raw = raw + } + + private _location: Location + /** + * Geolocation of the venue + */ + get location(): Location { + if (!this._location) { + assertTypeIs('Venue#location', this.raw.geo, 'geoPoint') + this._location = new Location(this.client, this.raw.geo) + } + + return this._location + } + + /** + * Venue name + */ + get title(): string { + return this.raw.title + } + + /** + * Venue address + */ + get address(): string { + return this.raw.address + } + + /** + * When available, source from where this venue was acquired + */ + get source(): Venue.VenueSource | null { + if (!this.raw.provider) return null + + return { + provider: this.raw.provider as Venue.VenueSource['provider'], + id: this.raw.venueId, + type: this.raw.venueType, + } + } +} + +makeInspectable(Venue) diff --git a/packages/client/src/types/messages/draft-message.ts b/packages/client/src/types/messages/draft-message.ts index 961553b6..8bad8b8c 100644 --- a/packages/client/src/types/messages/draft-message.ts +++ b/packages/client/src/types/messages/draft-message.ts @@ -7,7 +7,7 @@ import { MessageEntity } from './message-entity' import { Message } from './message' import { InputPeerLike } from '../peers' import { makeInspectable } from '../utils' -import { InputMediaLike } from '../media' +import { InputMediaWithCaption } from '../media' export class DraftMessage { readonly client: TelegramClient @@ -101,7 +101,7 @@ export class DraftMessage { * @link TelegramClient.sendMedia */ sendWithMedia( - media: InputMediaLike, + media: InputMediaWithCaption, params?: Parameters[2] ): Promise { if (!media.caption) { diff --git a/packages/client/src/types/messages/message.ts b/packages/client/src/types/messages/message.ts index 0769876d..f7495b18 100644 --- a/packages/client/src/types/messages/message.ts +++ b/packages/client/src/types/messages/message.ts @@ -21,12 +21,14 @@ import { LiveLocation, Sticker, Voice, - InputMediaLike, + InputMediaLike, Venue, } from '../media' import { parseDocument } from '../media/document-utils' import { Game } from '../media/game' import { WebPage } from '../media/web-page' import { InputFileLike } from '../files' +import { Poll } from '../media/poll' +import { Invoice } from '../media/invoice' /** * A message or a service message @@ -208,6 +210,9 @@ export namespace Message { | LiveLocation | Game | WebPage + | Venue + | Poll + | Invoice | null } @@ -655,12 +660,12 @@ export class Message { m._ === 'messageMediaGeo' && m.geo._ === 'geoPoint' ) { - media = new Location(m.geo) + media = new Location(this.client, m.geo) } else if ( m._ === 'messageMediaGeoLive' && m.geo._ === 'geoPoint' ) { - media = new LiveLocation(m) + media = new LiveLocation(this.client, m) } else if (m._ === 'messageMediaGame') { media = new Game(this.client, m.game) } else if ( @@ -668,6 +673,12 @@ export class Message { m.webpage._ === 'webPage' ) { media = new WebPage(this.client, m.webpage) + } else if (m._ === 'messageMediaVenue') { + media = new Venue(this.client, m) + } else if (m._ === 'messageMediaPoll') { + media = new Poll(this.client, m.poll, this._users, m.results) + } else if (m._ === 'messageMediaInvoice') { + media = new Invoice(this.client, m) } else { media = null } @@ -757,7 +768,6 @@ export class Message { .unparse(this.text, this.entities) } - // todo: bound methods https://github.com/pyrogram/pyrogram/blob/701c1cde07af779ab18dbf79a3e626f04fa5d5d2/pyrogram/types/messages_and_media/message.py#L737 /** * For replies, fetch the message that is being replied. * @@ -794,30 +804,6 @@ export class Message { return this.client.sendText(this.chat.inputPeer, text, params) } - /** - * Send a photo in reply to this message. - * - * By default just sends a message to the same chat, - * to make the reply a "real" reply, pass `visible=true` - * - * @param photo Photo to send - * @param visible Whether the reply should be visible - * @param params - */ - replyPhoto( - photo: InputFileLike, - visible = false, - params?: Parameters[2] - ): ReturnType { - if (visible) { - return this.client.sendPhoto(this.chat.inputPeer, photo, { - ...(params || {}), - replyTo: this.id, - }) - } - return this.client.sendPhoto(this.chat.inputPeer, photo, params) - } - /** * Send a media in reply to this message. * @@ -842,66 +828,6 @@ export class Message { return this.client.sendMedia(this.chat.inputPeer, media, params) } - /** - * Send a dice in reply to this message. - * - * By default just sends a message to the same chat, - * to make the reply a "real" reply, pass `visible=true` - * - * @param emoji Emoji representing a dice to send - * @param visible Whether the reply should be visible - * @param params - */ - replyDice( - emoji: string, - visible = false, - params?: Parameters[2] - ): ReturnType { - if (visible) { - return this.client.sendDice(this.chat.inputPeer, emoji, { - ...(params || {}), - replyTo: this.id, - }) - } - return this.client.sendDice(this.chat.inputPeer, emoji, params) - } - - /** - * Send a static geo location in reply to this message. - * - * By default just sends a message to the same chat, - * to make the reply a "real" reply, pass `visible=true` - * - * @param latitude Latitude of the location - * @param longitude Longitude of the location - * @param visible Whether the reply should be visible - * @param params - */ - replyLocation( - latitude: number, - longitude: number, - visible = false, - params?: Parameters[3] - ): ReturnType { - if (visible) { - return this.client.sendLocation( - this.chat.inputPeer, - latitude, - longitude, - { - ...(params || {}), - replyTo: this.id, - } - ) - } - return this.client.sendLocation( - this.chat.inputPeer, - latitude, - longitude, - params - ) - } - /** * Delete this message. * diff --git a/packages/dispatcher/src/filters.ts b/packages/dispatcher/src/filters.ts index d3713c11..fe79a32d 100644 --- a/packages/dispatcher/src/filters.ts +++ b/packages/dispatcher/src/filters.ts @@ -14,7 +14,7 @@ import { RawDocument, Sticker, TelegramClient, - User, + User, Venue, Video, Voice, } from '@mtcute/client' @@ -23,6 +23,8 @@ import { WebPage } from '@mtcute/client/src/types/media/web-page' import { MaybeArray } from '@mtcute/core' import { ChatMemberUpdate } from './updates' import { ChosenInlineResult } from './updates/chosen-inline-result' +import { Poll } from '@mtcute/client/src/types/media/poll' +import { Invoice } from '@mtcute/client/src/types/media/invoice' /** * Type describing a primitive filter, which is a function taking some `Base` @@ -482,7 +484,23 @@ export namespace filters { export const webpage: UpdateFilter = (msg) => msg.media instanceof WebPage - // todo: more filters, see https://github.com/pyrogram/pyrogram/blob/701c1cde07af779ab18dbf79a3e626f04fa5d5d2/pyrogram/filters.py#L191 + /** + * Filter messages containing a venue. + */ + export const venue: UpdateFilter = (msg) => + msg.media instanceof Venue + + /** + * Filter messages containing a poll. + */ + export const poll: UpdateFilter = (msg) => + msg.media instanceof Poll + + /** + * Filter messages containing an invoice. + */ + export const invoice: UpdateFilter = (msg) => + msg.media instanceof Invoice /** * Filter objects that match a given regular expression diff --git a/packages/dispatcher/src/updates/chosen-inline-result.ts b/packages/dispatcher/src/updates/chosen-inline-result.ts index 69892d4b..2e5fa393 100644 --- a/packages/dispatcher/src/updates/chosen-inline-result.ts +++ b/packages/dispatcher/src/updates/chosen-inline-result.ts @@ -66,7 +66,7 @@ export class ChosenInlineResult { if (this.raw.geo?._ !== 'geoPoint') return null if (!this._location) { - this._location = new Location(this.raw.geo) + this._location = new Location(this.client, this.raw.geo) } return this._location