Source: bot-utils.js

/**
 * @module bot-utils
 */

import * as fs from "node:fs";
import * as path from "node:path";
import * as https from "node:https";
import {Buffer} from "node:buffer";

/**
 * @description A helper class for uploading file.
 * @example
 * new InputFile(path)
 */
class InputFile {
  /**
   * @param {String} filepath
   * @param {Buffer} [buffer] If no buffer provided it will read from file.
   * @return {InputFile}
   */
  constructor(filepath, buffer = null) {
    this.filename = path.basename(filepath);
    if (buffer == null) {
      // Read file content from path.
      this.buffer = fs.readFileSync(filepath);
    } else {
      this.buffer = buffer;
    }
  }
}

/**
 * @see https://core.telegram.org/bots/api#inputmedia
 * @example
 * new InputMedia(type, media)
 */
class InputMedia {
  /**
   * @param {String} type
   * @param {(String|InputFile)} media File ID, URL or InputFile.
   * @param {Object} [opts] Optional Telegram parameters.
   * @return {InputMedia}
   */
  constructor(type, media, opts = {}) {
    this.type = type;
    this.media = media;
    opts = toSnakeCaseObject(opts);
    Object.assign(this, opts);
  }
}

/**
 * @see https://core.telegram.org/bots/api#inputmediaphoto
 * @example
 * new InputMediaPhoto(media)
 */
class InputMediaPhoto extends InputMedia {
  /**
   * @param {(String|InputFile)} media File ID, photo URL or InputFile.
   * @param {Object} [opts] Optional Telegram parameters.
   * @return {InputMedia}
   */
  constructor(media, opts = {}) {
    super("photo", media, opts);
  }
}

/**
 * @see https://core.telegram.org/bots/api#inputmediavideo
 * @example
 * new InputMediaVideo(media)
 */
class InputMediaVideo extends InputMedia {
  /**
   * @param {(String|InputFile)} media File ID, video URL or InputFile.
   * @param {Object} [opts] Optional Telegram parameters.
   * @return {InputMedia}
   */
  constructor(media, opts = {}) {
    super("video", media, opts);
  }
}

/**
 * @see https://core.telegram.org/bots/api#inputmediaanimation
 * @example
 * new InputMediaAnimation(media)
 */
class InputMediaAnimation extends InputMedia {
  /**
   * @param {(String|InputFile)} media File ID, animation URL or InputFile.
   * @param {Object} [opts] Optional Telegram parameters.
   * @return {InputMedia}
   */
  constructor(media, opts = {}) {
    super("animation", media, opts);
  }
}

/**
 * @see https://core.telegram.org/bots/api#inputmediaaudio
 * @example
 * new InputMediaAudio(media)
 */
class InputMediaAudio extends InputMedia {
  /**
   * @param {(String|InputFile)} media File ID, audio URL or InputFile.
   * @param {Object} [opts] Optional Telegram parameters.
   * @return {InputMedia}
   */
  constructor(media, opts = {}) {
    super("audio", media, opts);
  }
}

/**
 * @see https://core.telegram.org/bots/api#inputmediadocument
 * @example
 * new InputMediaDocument(media)
 */
class InputMediaDocument extends InputMedia {
  /**
   * @param {(String|InputFile)} media File ID, document URL or InputFile.
   * @param {Object} [opts] Optional Telegram parameters.
   * @return {InputMedia}
   */
  constructor(media, opts = {}) {
    super("document", media, opts);
  }
}

/**
 * @description A simple FormData implemention,
 * does not support mixed files because Telegram Bot API does not use it.
 * It is designed for upload files,
 * so don't think you can get your value as original type.
 * @example
 * new FormData()
 */
class FormData {
  /**
   * @return {FormData}
   */
  constructor() {
    this.buffer = null;
    this.boundary = `${Math.random().toString(16)}`;
    this.data = {};
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/append
   * @param {String} name
   * @param {(String|Buffer)} value
   * @param {String} [filename] If you want to upload a file, Telegram Bot API needs this.
   */
  append(name, value, filename = null) {
    name = `${name}`;
    if (!(isString(value) || isBuffer(value))) {
      value = `${value}`;
    }
    if (this.data[name] == null) {
      this.data[name] = [];
    }
    this.data[name].push({name, value, filename});
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/delete
   * @param {String} name
   */
  delete(name) {
    if (this.data[name] != null) {
      delete this.data[name];
    }
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/get
   * @param {String} name
   * @return {(String|Buffer)} Value
   */
  get(name) {
    if (this.data[name] == null) {
      return null;
    }
    return this.data[name][0]["value"];
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/getAll
   * @param {String} name
   * @return {Array} Array of value.
   */
  getAll(name) {
    if (this.data[name] == null) {
      return null;
    }
    return this.data[name].map((o) => {
      return o["value"];
    });
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/append
   * @param {String} name
   * @param {(String|Buffer)} value
   * @param {String} [filename] If you want to upload a file, Telegram Bot API needs this.
   */
  set(name, value, filename = null) {
    name = `${name}`;
    if (!(isString(value) || isBuffer(value))) {
      value = `${value}`;
    }
    this.data[name] = [{name, value, filename}];
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/has
   * @param {String} name
   * @return {Boolean}
   */
  has(name) {
    return this.data[name] != null;
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/keys
   * @return {Iterator<String>}
   */
  *keys() {
    for (const key of Object.keys(this.data)) {
      yield key;
    }
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/values
   * @return {Iterator<(String|Buffer)>}
   */
  *values() {
    for (const value of Object.values(this.data).reduce((acc, curr) => {
      return acc.concat(curr.map((o) => {
        return o["value"];
      }));
    })) {
      yield value;
    }
  }

  /**
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/entries
   * @return {Iterator<String, (String|Buffer)>}
   */
  *entries() {
    for (const entries of Object.entries(this.data).reduce((acc, curr) => {
      return acc.concat(curr[1].map((o) => {
        return [curr[0], o["value"]];
      }));
    })) {
      yield entries;
    }
  }

  /**
   * @description Get a buffer that can be written to http requests.
   * @return {Buffer}
   */
  getBuffer() {
    const array = [];
    for (const [name, values] of Object.entries(this.data)) {
      for (const o of values) {
        array.push(`\r\n--${this.boundary}\r\n`);
        array.push(`Content-Disposition: form-data; name="${name}"`);
        if (o["filename"] != null) {
          array.push(`; filename="${o["filename"]}"`);
        }
        array.push("\r\n\r\n");
        array.push(o["value"]);
      }
    }
    array.push(`\r\n--${this.boundary}--`);
    this.buffer = Buffer.concat(array.map(Buffer.from));
    return this.buffer;
  }

  /**
   * @description Get buffer length, you'd better call `getBuffer()` first.
   * @return {Number}
   */
  getLength() {
    if (this.buffer == null) {
      this.getBuffer();
    }
    return this.buffer.length;
  }

  /**
   * @description Get headers for buffer that can be past to http requests,
   * you'd better call `getBuffer()` first.
   * @return {Buffer}
   */
  getHeaders() {
    return {
      "Content-Type": `multipart/form-data; boundary=${this.boundary}`,
      "Transfer-Encoding": "chunked",
      "Content-Length": this.getLength()
    };
  }
}

/**
 * @param {String} url Target URL.
 * @param {Object} [headers]
 * @return {Promise<Buffer>}
 */
const get = (url, headers = {}) => {
  const timeout = 1500;
  const opts = {
    "method": "GET",
    "timeout": timeout,
    "headers": {}
  };
  for (const [k, v] of Object.entries(headers)) {
    opts["headers"][k.toLowerCase()] = v;
  }
  return new Promise((resolve, reject) => {
    const req = https.request(url, opts, (res) => {
      const chunks = [];
      res.on("error", reject);
      // See <https://github.com/axios/axios/blob/main/lib/adapters/http.js#L416-L420>.
      res.setTimeout(timeout, () => {
        res.destory(new Error("Response Error: Timeout."));
      });
      res.on("data", (chunk) => {
        chunks.push(chunk);
      });
      res.on("end", () => {
        resolve(Buffer.concat(chunks));
      });
    });
    req.on("error", reject);
    // See <https://github.com/axios/axios/blob/main/lib/adapters/http.js#L416-L420>.
    req.setTimeout(timeout, () => {
      req.destory(new Error("Request Error: Timeout."));
    });
    req.end();
  });
};

/**
 * @param {String} url Target URL.
 * @param {(String|Buffer|Object)} body Object will be JSON-serialized.
 * @param {Object} [headers]
 * @return {Promise<Buffer>}
 */
const post = (url, body, headers = {}) => {
  const timeout = 1500;
  const opts = {
    "method": "POST",
    "timeout": timeout,
    "headers": {}
  };
  for (const [k, v] of Object.entries(headers)) {
    opts["headers"][k.toLowerCase()] = v;
  }
  if (!(isBuffer(body) || isString(body))) {
    body = JSON.stringify(body);
    opts["headers"]["content-type"] = "application/json";
    opts["headers"]["content-length"] = `${Buffer.byteLength(body)}`;
  }
  return new Promise((resolve, reject) => {
    const req = https.request(url, opts, (res) => {
      const chunks = [];
      res.on("error", reject);
      // See <https://github.com/axios/axios/blob/main/lib/adapters/http.js#L416-L420>.
      res.setTimeout(timeout, () => {
        res.destory(new Error("Response Error: Timeout."));
      });
      res.on("data", (chunk) => {
        chunks.push(chunk);
      });
      res.on("end", () => {
        resolve(Buffer.concat(chunks));
      });
    });
    req.on("error", reject);
    // See <https://github.com/axios/axios/blob/main/lib/adapters/http.js#L416-L420>.
    req.setTimeout(timeout, () => {
      req.destory(new Error("Request Error: Timeout."));
    });
    req.write(body);
    req.end();
  });
};

/**
 * @param {Object} update Telegram update.
 * @return {String} A string which can be used as key.
 */
const perFromID = (update) => {
  if (update != null &&
      update["message"] != null &&
      update["message"]["from"] != null &&
      update["message"]["from"]["id"] != null) {
    return `${update["message"]["from"]["id"]}`;
  }
  return "0";
};

/**
 * @param {Object} update Telegram update.
 * @return {String} A string which can be used as key.
 */
const perChatID = (update) => {
  if (update != null &&
      update["message"] != null &&
      update["message"]["chat"] != null &&
      update["message"]["chat"]["id"] != null) {
    return `${update["message"]["chat"]["id"]}`;
  }
  return "0";
};

/**
 * @param {*} o
 * @return {Boolean}
 */
const isString = (o) => {
  return typeof (o) === "string" || o instanceof String;
};

/**
 * @param {*} o
 * @return {Boolean}
 */
const isArray = (o) => {
  return Array.isArray(o);
};

/**
 * @param {*} o
 * @return {Boolean}
 */
const isFunction = (o) => {
  return o instanceof Function;
};

/**
 * @param {*} o
 * @return {Boolean} Return `false` when `o == null`.
 */
const isObject = (o) => {
  return typeof (o) === "object" && o != null;
};

/**
 * @param {*} o
 * @return {Boolean}
 */
const isBuffer = (o) => {
  return Buffer.isBuffer(o);
};

/**
 * @param {*} o
 * @return {Boolean}
 */
const isFormData = (o) => {
  return o instanceof FormData;
};

/**
 * @description Replace camelCase to snake_case, e.g. `_ChatID` to `_chat_id`.
 * @param {String} camelCase
 * @return {String} snake_case of input.
 */
const toSnakeCase = (camelCase) => {
  // ['chatId', 'chatID', 'chat_ID', 'chat_id', 'chat__ID', 'chat_I_D', '_ChatID', 'chatID_', '_ID', 'ChatID'].map(toSnakeCase)
  return camelCase
    // CamelCase in line head is replaced by lower case.
    // This must be the first to escape from the 3rd regexp.
    // `Chat_id` to `chat_id`
    .replace(/^([A-Z]+)/g, (match, p1) => {
      return p1.toLowerCase();
    })
    // CamelCase after a underscore is replaced by lower case.
    // `chat_ID` to `chat_id`
    .replace(/(_[A-Z]+)/g, (match, p1) => {
      return p1.toLowerCase();
    })
    // CamelCase without a underscore and not in line head
    // is replaced by lower case with underscore.
    // `chatID` to `chat_id`
    .replace(/([A-Z]+)/g, (match, p1) => {
      return `_${p1.toLowerCase()}`;
    });
};

/**
 * @description Assign Objects into one Object which keys are all transfered
 * into snake_case.
 * @param {...Object}
 * @return {Object} Assigned snake_case Object.
 */
const toSnakeCaseObject = (...objects) => {
  const result = {};
  for (const object of objects) {
    for (const entry of Object.entries(object)) {
      // This does not work well with circular reference, but if you use
      // circular reference in API arguments, you are dead.
      result[toSnakeCase(entry[0])] =
        isObject(entry[1]) ? toSnakeCaseObject(entry[1]) : entry[1];
    }
  }
  return result;
};

/**
 * @description Assign Objects into one FormData which keys are all transfered
 * into snake_case.
 * @param {...Object}
 * @return {FormData} Assigned snake_case FormData.
 */
const toSnakeCaseFormData = (...objects) => {
  const formData = new FormData();
  for (const object of objects) {
    for (const entry of Object.entries(object)) {
      if (isObject(entry[1])) {
        // Append with a filename. We only handle file here, otherwise you
        // should not use FormData.
        formData.append(
          toSnakeCase(entry[0]),
          entry[1]["buffer"],
          entry[1]["filename"]
        );
      } else {
        formData.append(toSnakeCase(entry[0]), entry[1]);
      }
    }
  }
  return formData;
};

export {
  InputFile,
  InputMedia,
  InputMediaPhoto,
  InputMediaVideo,
  InputMediaAnimation,
  InputMediaAudio,
  InputMediaDocument,
  FormData,
  get,
  post,
  perFromID,
  perChatID,
  isString,
  isArray,
  isFunction,
  isObject,
  isBuffer,
  isFormData,
  toSnakeCase,
  toSnakeCaseObject,
  toSnakeCaseFormData
};