index.js

const { Composer } = require('opengram')
const { compactOptions } = require('./utils')
const d = require('debug')('opengram:media-group')

const storeSymbol = Symbol('store')
const timeoutSymbol = Symbol('store')
const allowedUpdatesSymbol = Symbol('allowedUpdates')
const latestMessage = Symbol('latestMessage')

/**
 * @typedef {Message[]} OpengramContext.mediaGroup
 */

/**
 * Copies media group to chat with given id
 *
 * ```js
 * bot.on('media_group', async ctx => {
 *   await ctx.copyMediaGroup(123456) // 123456 - chat id
 * })
 * ```
 * @param {number|string} chatId Unique identifier for the target chat or username of the target channel
 *   (in the format @channelusername)
 * @param {ExtraMediaGroup|Extra} extra Extra parameters
 * @param {AbortSignal} signal Abort signal
 * @this OpengramContext
 * @return {Promise<Message[]>}
 */
function copyMediaGroup (chatId, extra, signal) {
  if (!this.mediaGroup) {
    throw new TypeError(`"copyMediaGroup" isn't available for "${this.updateType}::${this.updateSubTypes}"`)
  }

  const mediaGroup = this.mediaGroup
    .map(m => {
      const type = ('photo' in m && 'photo') ||
        ('audio' in m && 'audio') ||
        ('video' in m && 'video') ||
        ('document' in m && 'document')

      /** @type {Audio|PhotoSize[]|Document|Video} **/
      const media = m[type]

      return compactOptions({
        type,
        caption: m.caption,
        caption_entities: m.caption_entities,
        has_spoiler: m.has_media_spoiler, // Photo / video
        media: type === 'photo' ? media[media.length - 1].file_id : media.file_id,
        thumbnail: media.thumbnail?.file_id, // Video / Audio
        duration: media.duration, // Video / Audio
        performer: media.performer, // Audio
        title: media.title, // Audio
        width: media.width, // Video
        height: media.height // Video
      })
    })

  return this.telegram.sendMediaGroup(chatId, mediaGroup, extra, signal)
}

/**
 * @typedef {object} MediaGroupOptions
 * @property {object} [store] Map-like object
 * @property {number} [timeout=100] Timeout in milliseconds. By default, `100`. After this timeout, the media group is
 *   considered fully received.
 * @property {Array<'video'|'audio'|'photo'|'document'>} [allowedUpdates] List of allowed updates.
 *   By default, `video`, `audio`, `photo` and `document`
 */

/**
 * A class that generates a middleware for merging media group messages into a single update
 * @class
 *
 * Usage example:
 *
 * ```js
 *  const { Opengram } = require('opengram')
 *  const { MediaGroup } = require('@opengram/media-group')
 *  const bot = new Opengram(process.env.BOT_TOKEN) // <-- put your bot token here (https://t.me/BotFather)
 *  const mediaGroup = new MediaGroup()
 *  bot.use(mediaGroup)
 *
 *  bot.on('media_group', async ctx => {
 *   // ctx.mediaGroup array of messages
 *   for (const message of ctx.mediaGroup) {
 *     console.log(JSON.stringify(message, null, 2)) // Pretty-print media group messages to console
 *   }
 *
 *   await ctx.copyMediaGroup(ctx.chat.id) // Copy media-group to current chat
 * })
 *
 *  bot.launch()
 *
 *  // Enable graceful stop
 *  process.once('SIGINT', () => bot.stop())
 *  process.once('SIGTERM', () => bot.stop())
 * ```
 */

class MediaGroup {
  /**
   * Media group plugin constructor
   * @param {MediaGroupOptions} [options] Options
   */
  constructor (options = { store: new Map(), timeout: 100, allowedUpdates: ['video', 'audio', 'photo', 'document'] }) {
    this[storeSymbol] = options.store
    this[timeoutSymbol] = options.timeout
    this[allowedUpdatesSymbol] = options.allowedUpdates
  }

  /**
   * Getter returns timeout in milliseconds
   * @returns {number}
   */
  get timeout () {
    return this[timeoutSymbol]
  }

  /**
   * Setter for timeout
   * @param {number} value Timeout in milliseconds
   * @returns {void}
   */
  set timeout (value) {
    this[timeoutSymbol] = value
  }

  /**
   * Getter returns store
   * @return {object}
   */
  get store () {
    return this[storeSymbol]
  }

  /**
   * Creates and return middleware for register
   * @return {Middleware}
   */
  middleware () {
    /**
     * @param {OpengramContext} ctx Context
     * @param {Function} next
     */
    const mediaGroup = async (ctx, next) => {
      ctx.copyMediaGroup = copyMediaGroup.bind(ctx)

      // eslint-disable-next-line camelcase
      const { media_group_id } = ctx.anyMessage
      // eslint-disable-next-line camelcase
      if (!media_group_id) {
        d('Message not related to media group, skipped %d', ctx.update.update_id)
        return next()
      }

      const store = this[storeSymbol]

      if (!store.has(ctx.chat.id)) {
        d('Create new Map for chat. Media-group ID: %d Chat ID: %d Update ID: %d', media_group_id, ctx.chat.id, ctx.update.update_id)
        store.set(ctx.chat.id, new Map())
      }

      const userMap = store.get(ctx.chat.id)

      if (!userMap.has(media_group_id)) {
        d('Add message to store. Media-group ID: %d Chat ID: %d Update ID: %d', media_group_id, ctx.chat.id, ctx.update.update_id)
        userMap.set(media_group_id, {
          resolve: () => {},
          messages: []
        })
      }

      const mediaGroupObject = userMap.get(media_group_id)

      mediaGroupObject.resolve(null)

      mediaGroupObject.messages.push(ctx.anyMessage)

      const result = mediaGroupObject.messages.length !== 10
        ? await new Promise((resolve) => {
          mediaGroupObject.resolve = resolve
          setTimeout(resolve, this[timeoutSymbol], latestMessage)
        })
        : latestMessage

      if (result === latestMessage) {
        if (mediaGroupObject.messages.length === 10) {
          d('Last message of media group received. Media-group ID: %d Chat ID: %d Update ID: %d', media_group_id, ctx.chat.id, ctx.update.update_id)
        } else {
          d('Timeout expired. Media-group ID: %d Chat ID: %d Update ID: %d', media_group_id, ctx.chat.id, ctx.update.update_id)
        }

        ctx.mediaGroup = mediaGroupObject.messages
          .slice()
          .sort((a, b) => a.message_id - b.message_id)

        ctx.updateSubTypes.push('media_group')
        d('Remove media group from Map. Media-group ID: %d Chat ID: %d Update ID: %d', media_group_id, ctx.chat.id, ctx.update.update_id)
        userMap.delete(media_group_id)

        if (userMap.size === 0) {
          d('All media groups for chat processed, cleanup. Media-group ID: %d Chat ID: %d Update ID: %d', media_group_id, ctx.chat.id, ctx.update.update_id)
          store.delete(ctx.chat.id)
        }
      }

      await next()
    }

    return Composer.mount(this[allowedUpdatesSymbol], mediaGroup)
  }
}

module.exports = { MediaGroup }