const debug = require('debug')('opengram:client')
const crypto = require('crypto')
const fetch = require('node-fetch').default
const fs = require('fs')
const https = require('https')
const path = require('path')
const util = require('util')
const { TelegramError } = require('../error')
const MultipartStream = require('./multipart-stream')
const { compactOptions } = require('../helpers/compact')
const { matchExceptionType, exceptionsHTTPCodes } = require('../exeptionsList')
const { Exceptions } = require('../exceptions')
const { isStream } = MultipartStream
const WEBHOOK_REPLY_METHOD_ALLOWLIST = new Set([
'setWebhook', 'deleteWebhook', 'sendChatAction', 'answerInlineQuery', 'setChatPermissions', 'banChatMember',
'promoteChatMember', 'restrictChatMember', 'banChatSenderChat', 'unbanChatSenderChat', 'unpinAllGeneralForumTopicMessages',
'setChatAdministratorCustomTitle', 'setChatPhoto', 'deleteChatPhoto', 'setChatTitle', 'setChatDescription',
'pinChatMessage', 'unpinChatMessage', 'unpinAllChatMessages', 'setChatMenuButton', 'setMyDefaultAdministratorRights',
'leaveChat', 'unbanChatMember', 'answerCallbackQuery', 'answerShippingQuery', 'answerPreCheckoutQuery',
'deleteMessage', 'setChatStickerSet', 'deleteChatStickerSet', 'editForumTopic', 'editGeneralForumTopic',
'closeGeneralForumTopic', 'reopenGeneralForumTopic', 'hideGeneralForumTopic', 'unhideGeneralForumTopic',
'closeForumTopic', 'reopenForumTopic', 'deleteForumTopic', 'unpinAllForumTopicMessages', 'createNewStickerSet',
'addStickerToSet', 'setStickerPositionInSet', 'setStickerSetThumb', 'deleteStickerFromSet', 'setMyCommands',
'deleteMyCommands', 'setPassportDataErrors', 'approveChatJoinRequest', 'declineChatJoinRequest', 'setMyName',
'setMyDescription', 'setMyShortDescription', 'setCustomEmojiStickerSetThumbnail', 'setStickerSetTitle',
'deleteStickerSet', 'setStickerEmojiList', 'setStickerKeywords', 'setStickerMaskPosition'
])
const DEFAULT_EXTENSIONS = {
audio: 'mp3',
photo: 'jpg',
sticker: 'webp',
video: 'mp4',
animation: 'mp4',
video_note: 'mp4',
voice: 'ogg'
}
const DEFAULT_OPTIONS = {
apiRoot: 'https://api.telegram.org',
apiPrefix: 'bot',
webhookReply: true,
agent: new https.Agent({
keepAlive: true,
keepAliveMsecs: 10000
}),
attachmentAgent: undefined,
testEnv: false
}
/** @type {WebhookResponse} */
const WEBHOOK_REPLY_STUB = {
webhook: true,
details: 'https://core.telegram.org/bots/api#making-requests-when-getting-updates'
}
function createError (err, on) {
const type = matchExceptionType(err)
if (type) {
return new Exceptions[type](err, on)
}
if (err.error_code) {
const type = exceptionsHTTPCodes[err.error_code]
if (type) {
return new Exceptions[type](err, on)
}
}
return new TelegramError(err, on)
}
// eslint-disable-next-line jsdoc/require-throws
/**
* Hides bot token in request errors
*
* @private
* @param {object} error JSON to parse
* @return {object}
*/
function redactToken (error) {
error.message = error.message.replace(
/(\d+):[^/]+\//,
'/$1:[REDACTED]/'
)
throw error
}
/**
* Parsing JSON without error throw if invalid
*
* @private
* @param {string} text JSON to parse
* @return {object|void}
*/
function safeJSONParse (text) {
try {
return JSON.parse(text)
} catch (err) {
debug('JSON parse failed', err)
}
}
/**
* Checks objects for media in props
*
* @private
* @param {object} payload Parameters object to check
* @return {boolean}
*/
function includesMedia (payload) {
return Object.keys(payload).some(
(key) => {
const value = payload[key]
if (Array.isArray(value)) {
return value.some(({ media }) => media && typeof media === 'object' && (media.source || media.url))
}
return (typeof value === 'object') && (
value.source ||
value.url ||
(typeof value.media === 'object' && (value.media.source || value.media.url))
)
}
)
}
/**
* Creates config object for API calls which not contains media with given payload
*
* @private
* @param {object} payload Parameters object
* @return {Promise<{headers: {'content-type': string, connection: string}, method: string, compress: boolean, body:
* string}>}
*/
function buildJSONConfig (payload) {
return Promise.resolve({
method: 'POST',
compress: true,
headers: { 'content-type': 'application/json', connection: 'keep-alive' },
body: JSON.stringify(payload)
})
}
const FORM_DATA_JSON_FIELDS = [
'results',
'reply_markup',
'mask_position',
'shipping_options',
'errors'
]
/**
* Creates config object for API calls which contains media with given payload
*
* @private
* @param {object} payload Parameters object
* @param {http.Agent} [agent] HTTP Agent
* @return {Promise<{headers: {'content-type': string, connection: string}, method: string, compress: boolean, body:
* MultipartStream}>}
*/
async function buildFormDataConfig (payload, agent) {
for (const field of FORM_DATA_JSON_FIELDS) {
if (field in payload && typeof payload[field] !== 'string') {
payload[field] = JSON.stringify(payload[field])
}
}
const boundary = crypto.randomBytes(32).toString('hex')
const formData = new MultipartStream(boundary)
const tasks = Object.keys(payload)
.map((key) => attachFormValue(formData, key, payload[key], agent))
await Promise.all(tasks)
return {
method: 'POST',
compress: true,
headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, connection: 'keep-alive' },
body: formData
}
}
/**
* Used to attach primitive values & media to form
*
* @param {MultipartStream} form MultipartStream instance
* @param {*} id Form field name
* @param {string|boolean|number|object} value Value to attach
* @param {http.Agent} [agent] HTTP Agent
* @return {Promise<void>}
*/
async function attachFormValue (form, id, value, agent) {
if (!value) {
return
}
const valueType = typeof value
if (valueType === 'string' || valueType === 'boolean' || valueType === 'number') {
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"` },
body: `${value}`
})
return
}
if (id === 'thumb') {
const attachmentId = crypto.randomBytes(16).toString('hex')
await attachFormMedia(form, value, attachmentId, agent)
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"` },
body: `attach://${attachmentId}`
})
return
}
if (Array.isArray(value)) {
const items = await Promise.all(
value.map(async item => {
if (typeof item.media !== 'object') {
return item
}
const attachmentId = crypto.randomBytes(16).toString('hex')
await attachFormMedia(form, item.media, attachmentId, agent)
return { ...item, media: `attach://${attachmentId}` }
})
)
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"` },
body: JSON.stringify(items)
})
return
}
if (typeof value.media !== 'undefined' && typeof value.type !== 'undefined') {
const attachmentId = crypto.randomBytes(16).toString('hex')
await attachFormMedia(form, value.media, attachmentId, agent)
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"` },
body: JSON.stringify({
...value,
media: `attach://${attachmentId}`
})
})
return
}
return attachFormMedia(form, value, id, agent)
}
/**
* @typedef {object} FileToAttach
* @property {string} [url] URL of file
* @property {string} filename Name of file
* @property {Stream|string|Buffer} source Path to file / Stream / Buffer
*/
/**
* Used to attach media to form
*
* @param {MultipartStream} form MultipartStream instance
* @param {string|boolean|number|FileToAttach} media Value to attach
* @param {*} id Form field name
* @param {http.Agent} [agent] HTTP Agent
* @return {Promise<void>}
*/
async function attachFormMedia (form, media, id, agent) {
let fileName = media.filename || `${id}.${DEFAULT_EXTENSIONS[id] || 'dat'}`
if (media.url !== undefined) {
const res = await fetch(media.url, { agent })
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"` },
body: res.body
})
return
}
if (media.source) {
if (fs.existsSync(media.source)) {
fileName = media.filename || path.basename(media.source)
media.source = fs.createReadStream(media.source)
}
if (isStream(media.source) || Buffer.isBuffer(media.source)) {
form.addPart({
headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"` },
body: media.source
})
}
}
}
/**
* Checking if response object belongs to KoaJs
*
* @private
* @param {http.ServerResponse} response Response object
* @return {boolean}
*/
function isKoaResponse (response) {
return typeof response.set === 'function' && typeof response.header === 'object'
}
/**
* @typedef {object} AnswerToWebhookOptions
* @property {http.Agent} [attachmentAgent] HTTP Agent used for attachments
*/
/**
* Answers to webhook
*
* @private
* @param {http.ServerResponse} response Server response object
* @param {object} payload Payload for API request
* @param {object} options Options
* @return {Promise<WebhookResponse>}
*/
async function answerToWebhook (response, payload = {}, options) {
if (!includesMedia(payload)) {
if (isKoaResponse(response)) {
response.body = payload
return WEBHOOK_REPLY_STUB
}
if (!response.headersSent) {
response.setHeader('content-type', 'application/json')
}
const responseEnd = util.promisify(response.end)
// Function.length returns count of arguments
// If arguments count equals 2, callback not available, return immediately
if (response.end.length === 2) {
response.end(JSON.stringify(payload), 'utf-8')
return WEBHOOK_REPLY_STUB
}
// If callback available, wait
await responseEnd.call(response, JSON.stringify(payload), 'utf-8')
return WEBHOOK_REPLY_STUB
}
const { headers, body } = await buildFormDataConfig(payload, options.attachmentAgent)
if (isKoaResponse(response)) {
Object.keys(headers).forEach(key => response.set(key, headers[key]))
response.body = body
return WEBHOOK_REPLY_STUB
}
if (!response.headersSent) {
Object.keys(headers).forEach(key => response.setHeader(key, headers[key]))
}
await new Promise(resolve => {
response.on('finish', resolve)
body.pipe(response)
})
return WEBHOOK_REPLY_STUB
}
/**
* The API client class implements a raw api call via http requests & webhook reply
*/
class ApiClient {
/**
* @param {string} token Bot token
* @param {TelegramOptions} [options] Options
* @param {http.ServerResponse} [webhookResponse] Response object from HTTP server for reply via webhook if enabled
*/
constructor (token, options, webhookResponse) {
this.token = token
this.options = {
...DEFAULT_OPTIONS,
...compactOptions(options)
}
if (this.options.apiRoot.startsWith('http://')) {
this.options.agent = null
}
this.response = webhookResponse
}
/**
* Setter for webhookReply
*
* Use this property to control reply via webhook feature.
*
* @param {boolean} enable Value
* @return {void}
*/
set webhookReply (enable) {
this.options.webhookReply = enable
}
/**
* Getter for webhookReply
*
* Use this property to control reply via webhook feature.
*
* @return {boolean}
*/
get webhookReply () {
return this.options.webhookReply
}
/**
* @typedef {object} CallApiExtra
* @property {AbortSignal} signal Optional `AbortSignal` to cancel the request
*/
/**
* Method for direct call telegram bots api methods
*
* Takes an optional `AbortSignal` object that allows to cancel the API call if desired.
*
* For example:
* ```js
* const controller = new AbortController();
* const signal = controller.signal;
* ctx.telegram.callApi ('getMe', {}, { signal })
* .then(console.log)
* .catch(err => {
* if (err instanceof AbortError) {
* console.log('API call aborted')
* } else throw err
* })
*
* controller.abort(); // Abort request
* ```
* [Read more about request aborts](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal)
*
* @param {string} method Telegram API method name
* @param {object} [data] Object with method parameters
* @param {CallApiExtra} [extra] Extra parameters
* @return {Promise<object|boolean|number>}
*/
async callApi (method, data = {}, extra = {}) {
const { token, options, response, responseEnd } = this
const payload = Object.keys(data)
.filter((key) => typeof data[key] !== 'undefined' && data[key] !== null)
.reduce((acc, key) => ({ ...acc, [key]: data[key] }), {})
if (options.webhookReply && response && !responseEnd && WEBHOOK_REPLY_METHOD_ALLOWLIST.has(method)) {
debug('Call via webhook', method, payload)
this.responseEnd = true
return await answerToWebhook(response, { method, ...payload }, options)
}
if (!token) {
throw createError({ error_code: 401, description: 'Bot Token is required' })
}
debug('HTTP call', method, payload)
const config = includesMedia(payload)
? await buildFormDataConfig({ method, ...payload }, options.attachmentAgent)
: await buildJSONConfig(payload)
const apiUrl = new URL(
`./${this.options.apiPrefix}${token}${options.testEnv ? '/test' : ''}/${method}`,
options.apiRoot
)
config.agent = options.agent
config.signal = extra.signal
const res = await fetch(apiUrl, config).catch(redactToken)
if (res.status >= 500) {
const errorPayload = {
error_code: res.status,
description: res.statusText
}
throw createError(errorPayload, { method, payload })
}
const text = await res.text()
const responseData = safeJSONParse(text) ?? {
error_code: 500,
description: 'Unsupported http response from Telegram',
response: text
}
if (!responseData.ok) {
debug('API call failed', responseData)
throw createError(responseData, { method, payload })
}
return responseData.result
}
}
module.exports = ApiClient