core_network_client.js

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