feat(client): a lot of changes

- support web documents
 - support previews for locations
 - unify media interfaces, merge everything into sendMedia
 - support invoices, polls, venues (both sending and receiving)
This commit is contained in:
teidesu 2021-05-07 14:26:29 +03:00
parent 169d95d6ed
commit 6db771e3da
23 changed files with 1319 additions and 518 deletions

View file

@ -72,8 +72,6 @@ import { _parseEntities } from './methods/messages/parse-entities'
import { pinMessage } from './methods/messages/pin-message' import { pinMessage } from './methods/messages/pin-message'
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 { sendDice } from './methods/messages/send-dice'
import { sendLocation } from './methods/messages/send-location'
import { sendMediaGroup } from './methods/messages/send-media-group' import { sendMediaGroup } from './methods/messages/send-media-group'
import { sendMedia } from './methods/messages/send-media' import { sendMedia } from './methods/messages/send-media'
import { sendText } from './methods/messages/send-text' import { sendText } from './methods/messages/send-text'
@ -1662,83 +1660,6 @@ export interface TelegramClient extends BaseTelegramClient {
chunkSize?: number chunkSize?: number
} }
): AsyncIterableIterator<Message> ): AsyncIterableIterator<Message>
/**
* 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<Message>
/**
* 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<Message>
/** /**
* Send a group of media. * Send a group of media.
* *
@ -2322,8 +2243,6 @@ export class TelegramClient extends BaseTelegramClient {
pinMessage = pinMessage pinMessage = pinMessage
searchGlobal = searchGlobal searchGlobal = searchGlobal
searchMessages = searchMessages searchMessages = searchMessages
sendDice = sendDice
sendLocation = sendLocation
sendMediaGroup = sendMediaGroup sendMediaGroup = sendMediaGroup
sendMedia = sendMedia sendMedia = sendMedia
sendText = sendText sendText = sendText

View file

@ -8,7 +8,7 @@ import {
FileDownloadParameters, FileDownloadParameters,
FileLocation, FileLocation,
} from '../../types' } 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 * 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 dcId = params.dcId
let fileSize = params.fileSize let fileSize = params.fileSize
let location = params.location const input = params.location
if (location instanceof FileLocation) { let location: tl.TypeInputFileLocation | tl.TypeInputWebFileLocation
if (typeof location.location === 'function') { if (input instanceof FileLocation) {
;(location as tl.Mutable<FileLocation>).location = location.location() if (typeof input.location === 'function') {
;(input as tl.Mutable<FileLocation>).location = input.location()
} }
if (location.location instanceof Buffer) { if (input.location instanceof Buffer) {
yield location.location yield input.location
return return
} }
if (!dcId) dcId = location.dcId if (!dcId) dcId = input.dcId
if (!fileSize) fileSize = location.fileSize if (!fileSize) fileSize = input.fileSize
location = location.location as any location = input.location as any
} } else if (typeof input === 'string') {
if (typeof location === 'string') { const parsed = parseFileId(input)
location = fileIdToInputFileLocation(location) 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 // we will receive a FileMigrateError in case this is invalid
if (!dcId) dcId = this._primaryDc.id if (!dcId) dcId = this._primaryDc.id
const chunkSize = partSizeKb * 1024 const chunkSize = partSizeKb * 1024
const limit = let limit =
params.limit ?? params.limit ??
(fileSize (fileSize
? // derive limit from chunk size, file size and offset ? // derive limit from chunk size, file size and offset
@ -77,13 +84,13 @@ export async function* downloadAsIterable(
} }
const requestCurrent = async (): Promise<Buffer> => { const requestCurrent = async (): Promise<Buffer> => {
let result: tl.RpcCallReturn['upload.getFile'] let result: tl.RpcCallReturn['upload.getFile'] | tl.RpcCallReturn['upload.getWebFile']
try { try {
result = await connection.sendForResult({ result = await connection.sendForResult({
_: 'upload.getFile', _: isWeb ? 'upload.getWebFile' : 'upload.getFile',
location: location as tl.TypeInputFileLocation, location: location as any,
offset, offset,
limit: chunkSize, limit: chunkSize
}) })
} catch (e) { } catch (e) {
if (e instanceof FileMigrateError) { if (e instanceof FileMigrateError) {
@ -94,18 +101,25 @@ export async function* downloadAsIterable(
} }
return requestCurrent() return requestCurrent()
} else if (e instanceof FilerefUpgradeNeededError) { } 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 // see: https://github.com/LonamiWebs/Telethon/blob/0e8bd8248cc649637b7c392616887c50986427a0/telethon/client/downloads.py#L99
throw new MtCuteUnsupportedError('File ref expired!') throw new MtCuteUnsupportedError('File ref expired!')
} else throw e } else throw e
} }
if (result._ === 'upload.fileCdnRedirect') { 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( throw new MtCuteUnsupportedError(
'Received CDN redirect, which is not supported (yet)' 'Received CDN redirect, which is not supported (yet)'
) )
} }
if (result._ === 'upload.webFile' && result.size && limit === Infinity) {
limit = result.size
}
return result.bytes return result.bytes
} }

View file

@ -14,6 +14,8 @@ import {
} from '@mtcute/file-id' } from '@mtcute/file-id'
import { extractFileName } from '../../utils/file-utils' import { extractFileName } from '../../utils/file-utils'
import { assertTypeIs } from '../../utils/type-assertion' import { assertTypeIs } from '../../utils/type-assertion'
import bigInt from 'big-integer'
import { normalizeDate } from '../../utils/misc-utils'
/** /**
* Normalize an {@link InputMediaLike} to `InputMedia`, * Normalize an {@link InputMediaLike} to `InputMedia`,
@ -25,6 +27,7 @@ export async function _normalizeInputMedia(
this: TelegramClient, this: TelegramClient,
media: InputMediaLike, media: InputMediaLike,
params: { params: {
parseMode?: string | null
progressCallback?: (uploaded: number, total: number) => void progressCallback?: (uploaded: number, total: number) => void
}, },
uploadMedia = false uploadMedia = false
@ -35,6 +38,160 @@ export async function _normalizeInputMedia(
if (tl.isAnyInputMedia(media)) return media 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 inputFile: tl.TypeInputFile | undefined = undefined
let thumb: tl.TypeInputFile | undefined = undefined let thumb: tl.TypeInputFile | undefined = undefined
let mime = 'application/octet-stream' let mime = 'application/octet-stream'
@ -56,18 +213,29 @@ export async function _normalizeInputMedia(
mime = uploaded.mime mime = uploaded.mime
} }
const uploadMediaIfNeeded = async (inputMedia: tl.TypeInputMedia, photo: boolean): Promise<tl.TypeInputMedia> => { const uploadMediaIfNeeded = async (
inputMedia: tl.TypeInputMedia,
photo: boolean
): Promise<tl.TypeInputMedia> => {
if (!uploadMedia) return inputMedia if (!uploadMedia) return inputMedia
const res = await this.call({ const res = await this.call({
_: 'messages.uploadMedia', _: 'messages.uploadMedia',
peer: { _: 'inputPeerSelf' }, peer: { _: 'inputPeerSelf' },
media: inputMedia media: inputMedia,
}) })
if (photo) { if (photo) {
assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res, 'messageMediaPhoto') assertTypeIs(
assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res.photo!, 'photo') 'normalizeInputMedia (@ messages.uploadMedia)',
res,
'messageMediaPhoto'
)
assertTypeIs(
'normalizeInputMedia (@ messages.uploadMedia)',
res.photo!,
'photo'
)
return { return {
_: 'inputMediaPhoto', _: 'inputMediaPhoto',
@ -75,13 +243,21 @@ export async function _normalizeInputMedia(
_: 'inputPhoto', _: 'inputPhoto',
id: res.photo.id, id: res.photo.id,
accessHash: res.photo.accessHash, accessHash: res.photo.accessHash,
fileReference: res.photo.fileReference fileReference: res.photo.fileReference,
}, },
ttlSeconds: media.ttlSeconds ttlSeconds: media.ttlSeconds,
} }
} else { } else {
assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res, 'messageMediaDocument') assertTypeIs(
assertTypeIs('normalizeInputMedia (@ messages.uploadMedia)', res.document!, 'document') 'normalizeInputMedia (@ messages.uploadMedia)',
res,
'messageMediaDocument'
)
assertTypeIs(
'normalizeInputMedia (@ messages.uploadMedia)',
res.document!,
'document'
)
return { return {
_: 'inputMediaDocument', _: 'inputMediaDocument',
@ -89,9 +265,9 @@ export async function _normalizeInputMedia(
_: 'inputDocument', _: 'inputDocument',
id: res.document.id, id: res.document.id,
accessHash: res.document.accessHash, 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 const input = media.file
if (tdFileId.isFileIdLike(input)) { if (tdFileId.isFileIdLike(input)) {
if (typeof input === 'string' && input.match(/^https?:\/\//)) { if (typeof input === 'string' && input.match(/^https?:\/\//)) {
return uploadMediaIfNeeded({ return uploadMediaIfNeeded(
{
_: _:
media.type === 'photo' media.type === 'photo'
? 'inputMediaPhotoExternal' ? 'inputMediaPhotoExternal'
: 'inputMediaDocumentExternal', : 'inputMediaDocumentExternal',
url: input, url: input,
}, media.type === 'photo') },
media.type === 'photo'
)
} else if (typeof input === 'string' && input.match(/^file:/)) { } else if (typeof input === 'string' && input.match(/^file:/)) {
await upload(input.substr(5)) await upload(input.substr(5))
} else { } else {
@ -118,13 +297,16 @@ export async function _normalizeInputMedia(
id: fileIdToInputPhoto(parsed), id: fileIdToInputPhoto(parsed),
} }
} else if (parsed.location._ === 'web') { } else if (parsed.location._ === 'web') {
return uploadMediaIfNeeded({ return uploadMediaIfNeeded(
{
_: _:
parsed.type === tdFileId.FileType.Photo parsed.type === tdFileId.FileType.Photo
? 'inputMediaPhotoExternal' ? 'inputMediaPhotoExternal'
: 'inputMediaDocumentExternal', : 'inputMediaDocumentExternal',
url: parsed.location.url, url: parsed.location.url,
}, parsed.type === tdFileId.FileType.Photo) },
parsed.type === tdFileId.FileType.Photo
)
} else { } else {
return { return {
_: 'inputMediaDocument', _: 'inputMediaDocument',
@ -146,11 +328,14 @@ export async function _normalizeInputMedia(
if (!inputFile) throw new Error('should not happen') if (!inputFile) throw new Error('should not happen')
if (media.type === 'photo') { if (media.type === 'photo') {
return uploadMediaIfNeeded({ return uploadMediaIfNeeded(
{
_: 'inputMediaUploadedPhoto', _: 'inputMediaUploadedPhoto',
file: inputFile, file: inputFile,
ttlSeconds: media.ttlSeconds, ttlSeconds: media.ttlSeconds,
}, true) },
true
)
} }
if ('thumb' in media && media.thumb) { if ('thumb' in media && media.thumb) {
@ -204,7 +389,8 @@ export async function _normalizeInputMedia(
}) })
} }
return uploadMediaIfNeeded({ return uploadMediaIfNeeded(
{
_: 'inputMediaUploadedDocument', _: 'inputMediaUploadedDocument',
nosoundVideo: media.type === 'video' && media.isAnimated, nosoundVideo: media.type === 'video' && media.isAnimated,
forceFile: media.type === 'document', forceFile: media.type === 'document',
@ -212,6 +398,8 @@ export async function _normalizeInputMedia(
thumb, thumb,
mimeType: mime, mimeType: mime,
attributes, attributes,
ttlSeconds: media.ttlSeconds ttlSeconds: media.ttlSeconds,
}, false) },
false
)
} }

View file

@ -97,7 +97,10 @@ export async function editInlineMessage(
if (params.media) { if (params.media) {
media = await this._normalizeInputMedia(params.media, params, true) 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( ;[content, entities] = await this._parseEntities(
params.media.caption, params.media.caption,
params.parseMode, params.parseMode,

View file

@ -84,11 +84,16 @@ export async function editMessage(
if (params.media) { if (params.media) {
media = await this._normalizeInputMedia(params.media, params) media = await this._normalizeInputMedia(params.media, params)
// 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( ;[content, entities] = await this._parseEntities(
params.media.caption, params.media.caption,
params.parseMode, params.parseMode,
params.media.entities params.media.entities
) )
}
} else { } else {
;[content, entities] = await this._parseEntities( ;[content, entities] = await this._parseEntities(
params.text, params.text,
@ -105,7 +110,7 @@ export async function editMessage(
replyMarkup: BotKeyboard._convertToTl(params.replyMarkup), replyMarkup: BotKeyboard._convertToTl(params.replyMarkup),
message: content, message: content,
entities, entities,
media media,
}) })
return this._findMessageInUpdate(res, true) as any return this._findMessageInUpdate(res, true) as any

View file

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

View file

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

View file

@ -63,7 +63,11 @@ export async function sendMediaGroup(
* @param uploaded Number of bytes already uploaded * @param uploaded Number of bytes already uploaded
* @param total Total file size * @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. * Whether to clear draft after sending this message.
@ -83,13 +87,16 @@ export async function sendMediaGroup(
for (let i = 0; i < medias.length; i++) { for (let i = 0; i < medias.length; i++) {
const media = medias[i] const media = medias[i]
const inputMedia = await this._normalizeInputMedia(media, { const inputMedia = await this._normalizeInputMedia(media, {
progressCallback: params.progressCallback?.bind(null, i) progressCallback: params.progressCallback?.bind(null, i),
}) })
const [message, entities] = await this._parseEntities( 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, params.parseMode,
media.entities (media as any).entities
) )
multiMedia.push({ multiMedia.push({
@ -97,7 +104,7 @@ export async function sendMediaGroup(
randomId: randomUlong(), randomId: randomUlong(),
media: inputMedia, media: inputMedia,
message, message,
entities entities,
}) })
} }

View file

@ -86,9 +86,12 @@ export async function sendMedia(
const inputMedia = await this._normalizeInputMedia(media, params) const inputMedia = await this._normalizeInputMedia(media, params)
const [message, entities] = await this._parseEntities( 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, params.parseMode,
media.entities (media as any).entities
) )
const peer = normalizeToInputPeer(await this.resolvePeer(chatId)) const peer = normalizeToInputPeer(await this.resolvePeer(chatId))

View file

@ -1,6 +1,12 @@
import { tl } from '@mtcute/tl' import { tl } from '@mtcute/tl'
import { BotKeyboard, ReplyMarkup } from '../keyboards' import { BotKeyboard, ReplyMarkup } from '../keyboards'
import { TelegramClient } from '../../../client' import { TelegramClient } from '../../../client'
import {
InputMediaGeo,
InputMediaGeoLive,
InputMediaVenue,
Venue,
} from '../../media'
/** /**
* Inline message containing only text * Inline message containing only text
@ -57,37 +63,17 @@ export interface InputInlineMessageMedia {
/** /**
* Inline message containing a geolocation * Inline message containing a geolocation
*/ */
export interface InputInlineMessageGeo { export interface InputInlineMessageGeo extends InputMediaGeo {
type: 'geo'
/** /**
* Latitude of the geolocation * Message's reply markup
*/ */
latitude: number replyMarkup?: ReplyMarkup
}
/** /**
* Longitude of the geolocation * Inline message containing a live geolocation
*/ */
longitude: number export interface InputInlineMessageGeoLive extends InputMediaGeoLive {
/**
* 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
/** /**
* Message's reply markup * Message's reply markup
*/ */
@ -97,54 +83,7 @@ export interface InputInlineMessageGeo {
/** /**
* Inline message containing a venue * Inline message containing a venue
*/ */
export interface InputInlineMessageVenue { export interface InputInlineMessageVenue extends 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?: {
/**
* 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
}
/** /**
* Message's reply markup * Message's reply markup
*/ */
@ -167,51 +106,62 @@ export type InputInlineMessage =
| InputInlineMessageText | InputInlineMessageText
| InputInlineMessageMedia | InputInlineMessageMedia
| InputInlineMessageGeo | InputInlineMessageGeo
| InputInlineMessageGeoLive
| InputInlineMessageVenue | InputInlineMessageVenue
| InputInlineMessageGame | InputInlineMessageGame
export namespace BotInlineMessage { export namespace BotInlineMessage {
export function text ( export function text(
text: string, text: string,
params?: Omit<InputInlineMessageText, 'type' | 'text'>, params?: Omit<InputInlineMessageText, 'type' | 'text'>
): InputInlineMessageText { ): InputInlineMessageText {
return { return {
type: 'text', type: 'text',
text, text,
...( ...(params || {}),
params || {}
),
} }
} }
export function media ( export function media(
params?: Omit<InputInlineMessageMedia, 'type'>, params?: Omit<InputInlineMessageMedia, 'type'>
): InputInlineMessageMedia { ): InputInlineMessageMedia {
return { return {
type: 'media', type: 'media',
...( ...(params || {}),
params || {}
),
} }
} }
export function geo ( export function geo(
latitude: number, latitude: number,
longitude: number, longitude: number,
params?: Omit<InputInlineMessageGeo, 'type' | 'latitude' | 'longitude'>, params?: Omit<InputInlineMessageGeo, 'type' | 'latitude' | 'longitude'>
): InputInlineMessageGeo { ): InputInlineMessageGeo {
return { return {
type: 'geo', type: 'geo',
latitude, latitude,
longitude, longitude,
...( ...(params || {}),
params || {}
),
} }
} }
export function venue ( export function geoLive(
params: Omit<InputInlineMessageVenue, 'type'>, latitude: number,
longitude: number,
params?: Omit<
InputInlineMessageGeoLive,
'type' | 'latitude' | 'longitude'
>
): InputInlineMessageGeoLive {
return {
type: 'geo_live',
latitude,
longitude,
...(params || {}),
}
}
export function venue(
params: Omit<InputInlineMessageVenue, 'type'>
): InputInlineMessageVenue { ): InputInlineMessageVenue {
return { return {
type: 'venue', type: 'venue',
@ -219,8 +169,8 @@ export namespace BotInlineMessage {
} }
} }
export function game ( export function game(
params: Omit<InputInlineMessageGame, 'type'>, params: Omit<InputInlineMessageGame, 'type'>
): InputInlineMessageGame { ): InputInlineMessageGame {
return { return {
type: 'game', type: 'game',
@ -228,45 +178,55 @@ export namespace BotInlineMessage {
} }
} }
export async function _convertToTl ( export async function _convertToTl(
client: TelegramClient, client: TelegramClient,
obj: InputInlineMessage, obj: InputInlineMessage,
parseMode?: string | null, parseMode?: string | null
): Promise<tl.TypeInputBotInlineMessage> { ): Promise<tl.TypeInputBotInlineMessage> {
if (obj.type === 'text') { 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 { return {
_: 'inputBotInlineMessageText', _: 'inputBotInlineMessageText',
message, message,
entities, entities,
replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup),
} }
} }
if (obj.type === 'media') { 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 { return {
_: 'inputBotInlineMessageMediaAuto', _: 'inputBotInlineMessageMediaAuto',
message, message,
entities, entities,
replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup),
} }
} }
if (obj.type === 'geo') { if (obj.type === 'geo' || obj.type === 'geo_live') {
return { return {
_: 'inputBotInlineMessageMediaGeo', _: 'inputBotInlineMessageMediaGeo',
geoPoint: { geoPoint: {
_: 'inputGeoPoint', _: 'inputGeoPoint',
lat: obj.latitude, lat: obj.latitude,
long: obj.longitude long: obj.longitude,
}, },
heading: obj.heading, // fields will be `undefined` if this is a `geo`
period: obj.period, heading: (obj as InputMediaGeoLive).heading,
proximityNotificationRadius: obj.proximityNotificationRadius, period: (obj as InputMediaGeoLive).period,
replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) proximityNotificationRadius: (obj as InputMediaGeoLive)
.proximityNotificationRadius,
replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup),
} }
} }
@ -276,21 +236,21 @@ export namespace BotInlineMessage {
geoPoint: { geoPoint: {
_: 'inputGeoPoint', _: 'inputGeoPoint',
lat: obj.latitude, lat: obj.latitude,
long: obj.longitude long: obj.longitude,
}, },
title: obj.title, title: obj.title,
address: obj.address, address: obj.address,
provider: obj.source?.provider ?? '', provider: obj.source?.provider ?? '',
venueId: obj.source?.id ?? '', venueId: obj.source?.id ?? '',
venueType: obj.source?.type ?? '', venueType: obj.source?.type ?? '',
replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup),
} }
} }
if (obj.type === 'game') { if (obj.type === 'game') {
return { return {
_: 'inputBotInlineMessageGame', _: 'inputBotInlineMessageGame',
replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup),
} }
} }

View file

@ -27,8 +27,9 @@ export class FileLocation {
*/ */
readonly location: readonly location:
| tl.TypeInputFileLocation | tl.TypeInputFileLocation
| tl.TypeInputWebFileLocation
| Buffer | Buffer
| (() => tl.TypeInputFileLocation | Buffer) | (() => tl.TypeInputFileLocation | tl.TypeInputWebFileLocation | Buffer)
/** /**
* File size in bytes, when available * File size in bytes, when available
@ -44,8 +45,9 @@ export class FileLocation {
client: TelegramClient, client: TelegramClient,
location: location:
| tl.TypeInputFileLocation | tl.TypeInputFileLocation
| tl.TypeInputWebFileLocation
| Buffer | Buffer
| (() => tl.TypeInputFileLocation | Buffer), | (() => tl.TypeInputFileLocation | tl.TypeInputWebFileLocation | Buffer),
fileSize?: number, fileSize?: number,
dcId?: number dcId?: number
) { ) {
@ -82,7 +84,7 @@ export class FileLocation {
* in chunks of a given size. Order of the chunks is guaranteed to be * in chunks of a given size. Order of the chunks is guaranteed to be
* consecutive. * consecutive.
* *
* Shorthand for `client.downloadAsStream({ location: this })` * Shorthand for `client.downloadAsIterable({ location: this })`
* *
* @link TelegramClient.downloadAsIterable * @link TelegramClient.downloadAsIterable
*/ */

View file

@ -52,7 +52,7 @@ export interface FileDownloadParameters {
* File location which should be downloaded. * File location which should be downloaded.
* You can also provide TDLib and Bot API compatible File ID * 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. * Total file size, if known.

View file

@ -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)

View file

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

View file

@ -1,5 +1,7 @@
import { InputFileLike } from '../files' import { InputFileLike } from '../files'
import { tl } from '@mtcute/tl' import { tl } from '@mtcute/tl'
import { Venue } from './venue'
import { MaybeArray } from '@mtcute/core'
interface BaseInputMedia { interface BaseInputMedia {
/** /**
@ -233,6 +235,303 @@ export interface InputMediaVideo extends BaseInputMedia {
isRound?: boolean 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<InputMediaGeo, 'type'> {
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<InputMediaPoll, 'type'> {
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<number | Buffer>
/**
* 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. * Input media that can be sent somewhere.
* *
@ -243,17 +542,24 @@ export interface InputMediaVideo extends BaseInputMedia {
* @link InputMedia * @link InputMedia
*/ */
export type InputMediaLike = export type InputMediaLike =
| InputMediaAudio | InputMediaWithCaption
| InputMediaVoice
| InputMediaDocument
| InputMediaPhoto
| InputMediaVideo
| InputMediaAuto
| InputMediaSticker | InputMediaSticker
| InputMediaVenue
| InputMediaGeo
| InputMediaGeoLive
| InputMediaDice
| InputMediaContact
| InputMediaGame
| InputMediaInvoice
| InputMediaPoll
| InputMediaQuiz
| tl.TypeInputMedia | tl.TypeInputMedia
export namespace InputMedia { export namespace InputMedia {
type OmitTypeAndFile<T extends InputMediaLike> = Omit<T, 'type' | 'file'> type OmitTypeAndFile<
T extends InputMediaLike,
K extends keyof T = never
> = Omit<T, 'type' | 'file' | K>
/** /**
* Create an animation to be sent * 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>
): InputMediaVenue {
return {
type: 'venue',
...params,
}
}
/**
* Create a geolocation to be sent
*/
export function geo(
latitude: number,
longitude: number,
params?: OmitTypeAndFile<InputMediaGeo, 'latitude' | 'longitude'>
): InputMediaGeo {
return {
type: 'geo',
latitude,
longitude,
...(params || {}),
}
}
/**
* Create a live geolocation to be sent
*/
export function geoLive(
latitude: number,
longitude: number,
params?: OmitTypeAndFile<InputMediaGeoLive, 'latitude' | 'longitude'>
): 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>
): 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>
): InputMediaInvoice {
return {
type: 'invoice',
...params,
}
}
/**
* Create a poll to be sent
*/
export function poll(
params: OmitTypeAndFile<InputMediaPoll>
): InputMediaPoll {
return {
type: 'poll',
...params,
}
}
/**
* Create a quiz to be sent
*/
export function quiz(
params: OmitTypeAndFile<InputMediaQuiz>
): InputMediaQuiz {
return {
type: 'quiz',
...params,
}
}
/** /**
* Create a document to be sent, which subtype * Create a document to be sent, which subtype
* is inferred automatically by file contents. * is inferred automatically by file contents.

View file

@ -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)

View file

@ -1,13 +1,17 @@
import { makeInspectable } from '../utils' import { makeInspectable } from '../utils'
import { tl } from '@mtcute/tl' import { tl } from '@mtcute/tl'
import { FileLocation } from '../files'
import { TelegramClient } from '../../client'
/** /**
* A point on the map * A point on the map
*/ */
export class Location { export class Location {
readonly client: TelegramClient
readonly geo: tl.RawGeoPoint readonly geo: tl.RawGeoPoint
constructor(geo: tl.RawGeoPoint) { constructor(client: TelegramClient, geo: tl.RawGeoPoint) {
this.client = client
this.geo = geo this.geo = geo
} }
@ -31,13 +35,62 @@ export class Location {
get radius(): number { get radius(): number {
return this.geo.accuracyRadius ?? 0 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 { export class LiveLocation extends Location {
readonly live: tl.RawMessageMediaGeoLive readonly live: tl.RawMessageMediaGeoLive
constructor(live: tl.RawMessageMediaGeoLive) { constructor(client: TelegramClient, live: tl.RawMessageMediaGeoLive) {
super(live.geo as tl.RawGeoPoint) super(client, live.geo as tl.RawGeoPoint)
this.live = live this.live = live
} }

View file

@ -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<number, tl.TypeUser>
constructor(
client: TelegramClient,
raw: tl.TypePoll,
users: Record<number, tl.TypeUser>,
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)

View file

@ -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)

View file

@ -7,7 +7,7 @@ import { MessageEntity } from './message-entity'
import { Message } from './message' import { Message } from './message'
import { InputPeerLike } from '../peers' import { InputPeerLike } from '../peers'
import { makeInspectable } from '../utils' import { makeInspectable } from '../utils'
import { InputMediaLike } from '../media' import { InputMediaWithCaption } from '../media'
export class DraftMessage { export class DraftMessage {
readonly client: TelegramClient readonly client: TelegramClient
@ -101,7 +101,7 @@ export class DraftMessage {
* @link TelegramClient.sendMedia * @link TelegramClient.sendMedia
*/ */
sendWithMedia( sendWithMedia(
media: InputMediaLike, media: InputMediaWithCaption,
params?: Parameters<TelegramClient['sendMedia']>[2] params?: Parameters<TelegramClient['sendMedia']>[2]
): Promise<Message> { ): Promise<Message> {
if (!media.caption) { if (!media.caption) {

View file

@ -21,12 +21,14 @@ import {
LiveLocation, LiveLocation,
Sticker, Sticker,
Voice, Voice,
InputMediaLike, InputMediaLike, Venue,
} from '../media' } from '../media'
import { parseDocument } from '../media/document-utils' import { parseDocument } from '../media/document-utils'
import { Game } from '../media/game' import { Game } from '../media/game'
import { WebPage } from '../media/web-page' import { WebPage } from '../media/web-page'
import { InputFileLike } from '../files' import { InputFileLike } from '../files'
import { Poll } from '../media/poll'
import { Invoice } from '../media/invoice'
/** /**
* A message or a service message * A message or a service message
@ -208,6 +210,9 @@ export namespace Message {
| LiveLocation | LiveLocation
| Game | Game
| WebPage | WebPage
| Venue
| Poll
| Invoice
| null | null
} }
@ -655,12 +660,12 @@ export class Message {
m._ === 'messageMediaGeo' && m._ === 'messageMediaGeo' &&
m.geo._ === 'geoPoint' m.geo._ === 'geoPoint'
) { ) {
media = new Location(m.geo) media = new Location(this.client, m.geo)
} else if ( } else if (
m._ === 'messageMediaGeoLive' && m._ === 'messageMediaGeoLive' &&
m.geo._ === 'geoPoint' m.geo._ === 'geoPoint'
) { ) {
media = new LiveLocation(m) media = new LiveLocation(this.client, m)
} else if (m._ === 'messageMediaGame') { } else if (m._ === 'messageMediaGame') {
media = new Game(this.client, m.game) media = new Game(this.client, m.game)
} else if ( } else if (
@ -668,6 +673,12 @@ export class Message {
m.webpage._ === 'webPage' m.webpage._ === 'webPage'
) { ) {
media = new WebPage(this.client, m.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 { } else {
media = null media = null
} }
@ -757,7 +768,6 @@ export class Message {
.unparse(this.text, this.entities) .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. * 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) 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<TelegramClient['sendPhoto']>[2]
): ReturnType<TelegramClient['sendPhoto']> {
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. * Send a media in reply to this message.
* *
@ -842,66 +828,6 @@ export class Message {
return this.client.sendMedia(this.chat.inputPeer, media, params) 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<TelegramClient['sendDice']>[2]
): ReturnType<TelegramClient['sendDice']> {
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<TelegramClient['sendLocation']>[3]
): ReturnType<TelegramClient['sendLocation']> {
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. * Delete this message.
* *

View file

@ -14,7 +14,7 @@ import {
RawDocument, RawDocument,
Sticker, Sticker,
TelegramClient, TelegramClient,
User, User, Venue,
Video, Video,
Voice, Voice,
} from '@mtcute/client' } from '@mtcute/client'
@ -23,6 +23,8 @@ import { WebPage } from '@mtcute/client/src/types/media/web-page'
import { MaybeArray } from '@mtcute/core' import { MaybeArray } from '@mtcute/core'
import { ChatMemberUpdate } from './updates' import { ChatMemberUpdate } from './updates'
import { ChosenInlineResult } from './updates/chosen-inline-result' 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` * Type describing a primitive filter, which is a function taking some `Base`
@ -482,7 +484,23 @@ export namespace filters {
export const webpage: UpdateFilter<Message, { media: WebPage }> = (msg) => export const webpage: UpdateFilter<Message, { media: WebPage }> = (msg) =>
msg.media instanceof WebPage 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<Message, { media: Venue }> = (msg) =>
msg.media instanceof Venue
/**
* Filter messages containing a poll.
*/
export const poll: UpdateFilter<Message, { media: Poll }> = (msg) =>
msg.media instanceof Poll
/**
* Filter messages containing an invoice.
*/
export const invoice: UpdateFilter<Message, { media: Invoice }> = (msg) =>
msg.media instanceof Invoice
/** /**
* Filter objects that match a given regular expression * Filter objects that match a given regular expression

View file

@ -66,7 +66,7 @@ export class ChosenInlineResult {
if (this.raw.geo?._ !== 'geoPoint') return null if (this.raw.geo?._ !== 'geoPoint') return null
if (!this._location) { if (!this._location) {
this._location = new Location(this.raw.geo) this._location = new Location(this.client, this.raw.geo)
} }
return this._location return this._location