Source: routes/chat.js

module.exports = Chat

var debug = require('debug')('snapchat:chat')
var async = require('async')
var extend = require('xtend')
var Promise = require('bluebird')

var constants = require('../lib/constants')
var StringUtils = require('../lib/string-utils')

var Conversation = require('../models/conversation')

/**
 * Snapchat wrapper for chat-related API calls.
 *
 * @class
 * @param {Object} opts
 */
function Chat (client, opts) {
  var self = this
  if (!(self instanceof Chat)) return new Chat(client, opts)
  if (!opts) opts = {}

  self.client = client
}

/**
 * Sends the typing notification to the given users.
 *
 * @param {Array<string>} recipients An array of username strings.
 * @param {function} cb
 */
Chat.prototype.sendTypingToUsers = function (recipients, cb) {
  var self = this
  debug('Chat.sendTypingToUsers')

  return self._sendTyping(recipients, cb)
}

/**
 * Sends the typing notification to a single user.
 *
 * @param {string} username
 * @param {function} cb
 */
Chat.prototype.sendTypingToUser = function (username, cb) {
  var self = this
  debug('Chat.sendTypingToUser (%s)', username)

  return self._sendTyping([ username ], cb)
}

/**
 * Marks all chat messages in a conversation as read.
 *
 * @TODO currently not working
 * @param {Conversation} conversation
 * @param {function} cb
 */
Chat.prototype.markRead = function (conversation, cb) {
  var self = this
  debug('Chat.markRead (%s)', conversation.identifier)

  return self.client.sendEvents([
    {
      'eventName': 'CHAT_TEXT_VIEWED',
      'params': {
        'id': conversation.identifier
      },
      'ts': StringUtils.timestamp() | 0
    }
  ], null, cb)
}

/**
 * Retrieves the conversation auth mac and payload for a conversation with username.
 *
 * @param {string} username
 * @param {function} cb
 */
Chat.prototype.conversationAuth = function (username, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.conversationAuth (%s)', username)

    var cid = StringUtils.SCIdentifier(self.client.username, username)

    self.client.post(constants.endpoints.chat.authToken, {
      'username': self.client.username,
      'conversation_id': cid
    }, function (err, result) {
      if (err) {
        return reject(err)
      } else if (result) {
        result = result['messaging_auth']

        if (result && result['mac'] && result['payload']) {
          return resolve(result)
        }

        return reject(new Error('Chat.conversationAuth parse error'))
      }
    })
  }).nodeify(cb)
}

/**
 * Retrieves the conversation with \e username.
 *
 * @param {string} username
 * @param {function} cb
 */
Chat.prototype.conversationWithUser = function (username, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.conversationWithUser (%s)', username)

    self.conversationsWithUsers([ username ], function (err, results) {
      if (err) {
        return reject(err)
      } else {
        return resolve(results.conversations[0])
      }
    })
  }).nodeify(cb)
}

/**
 * Fetches the conversations for all users in \e usernames
 *
 * @param {Array<string>} usernames
 * @param {function} cb
 */
Chat.prototype.conversationsWithUsers = function (usernames, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.conversationsWithUsers (%j)', usernames)

    var results = {
      conversations: [ ],
      failed: [ ],
      errors: [ ]
    }

    var messages = [ ]

    async.eachLimit(usernames, 4, function (username, cb) {
      self.conversationAuth(username, function (err, auth) {
        if (err) {
          results.failed.push(username)
          results.errors.push(err)
          return cb(err)
        } else {
          var identifier = StringUtils.uniqueIdentifer()
          var header = {
            'auth': auth,
            'to': [ username ],
            'conv_id': StringUtils.SCIdentifier(self.client.username, username),
            'from': self.client.username,
            'conn_sequence_number': 0
          }

          var first = {
            'presences': { },
            'receiving_video': false,
            'supports_here': true,
            'header': header,
            'retried': false,
            'id': identifier,
            'type': 'presence'
          }

          first.presences[self.client.username] = true
          first.presences[username] = false

          var header2 = extend(header, {
            'conv_id': StringUtils.SCIdentifier(username, self.client.username)
          })

          var second = {
            'presences': { },
            'receiving_video': false,
            'supports_here': true,
            'header': header2,
            'retried': false,
            'id': identifier,
            'type': 'presence'
          }

          second.presences[self.client.username] = true
          second.presences[username] = false

          messages.push(first)
          messages.push(second)
          return cb(null)
        }
      })
    }, function () {
      self.client.post(constants.endpoints.chat.sendMessage, {
        'auth_token': self.client.authToken,
        'messages': JSON.stringify(messages),
        'username': self.client.username
      }, function (err, result) {
        if (err) {
          return reject(err)
        } else if (result) {
          if (result.conversations) {
            results.conversations = result.conversations.map(function (convo) {
              return new Conversation(convo)
            })
            return resolve(results)
          } else {
            debug('Chat.conversationsWithUsers parse error %j', result)
          }
        }

        return reject(new Error('Chat.conversationsWithUsers parse error'))
      })
    })
  }).nodeify(cb)
}

/**
 * Clears the conversation with the given identifier.
 *
 * @param {string} identifier The identifier of the conversation to clear.
 * @param {function} cb
 */
Chat.prototype.clearConversationWithIdentifier = function (identifier, cb) {
  var self = this
  debug('Chat.clearConversationWithIdentifier (%s)', identifier)

  return self.client.post(constants.endpoints.chat.clearConvo, {
    'conversation_id': identifier,
    'username': self.client.username
  }, cb)
}

/**
 * Clears the entire feed.
 *
 * @param {function} cb
 */
Chat.prototype.clearFeed = function (cb) {
  var self = this
  debug('Chat.clearFeed')

  return self.client.post(constants.endpoints.chat.clearFeed, {
    'username': self.client.username
  }, cb)
}

/**
 * Sends a message \e message to \e username.
 *
 * @param {string} message The message to send.
 * @param {Array<string>} username The username of the recipient.
 * @param {function} cb
 */
Chat.prototype.sendMessage = function (message, username, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.sendMessage ("%s", %s)', message, username)

    self.sendMessageToUsers(message, [ username ], function (err, results) {
      if (err) {
        return reject(err)
      } else if (!results.conversations.length) {
        return reject(new Error('Chat.conversationWithUser error'))
      } else {
        return resolve(results.conversations[0])
      }
    })
  }).nodeify(cb)
}

/**
 * Sends a message \e message to each user in \e usernames.
 *
 * @TODO: what to do if message fails to send
 *
 * @param {string} message The message to send.
 * @param {Array<string>} usernames An array of username strings as recipients.
 * @param {function} cb
 */
Chat.prototype.sendMessageToUsers = function (message, usernames, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.sendMessageToUsers ("%s", %j)', message, usernames)

    var results = {
      conversations: [ ],
      failed: [ ],
      errors: [ ]
    }

    self.conversationsWithUsers(usernames, function (err, convoResults) {
      if (err) {
        return reject(err)
      }

      results.failed = convoResults.failed
      results.errors = convoResults.errors

      var messages = convoResults.conversations.map(function (convo) {
        var identifier = StringUtils.uniqueIdentifer()
        var sequenceNum = convo.state['conversation_state']
        sequenceNum = sequenceNum && sequenceNum['user_sequences']
        sequenceNum = sequenceNum && sequenceNum[self.client.username]
        sequenceNum = sequenceNum | 0

        var header = {
          'auth': convo.messagingAuth,
          'to': [ convo.recipient ],
          'from': self.client.username,
          // 'conn_sequence_number': 1,
          'conn_sequ_num': 1,
          'conv_id': convo.identifier
        }

        return {
          'body': { 'type': 'text', 'text': message },
          'chat_message_id': identifier,
          'seq_num': sequenceNum + 1,
          'timestamp': StringUtils.timestamp(),
          'retried': false,
          'id': identifier,
          'type': 'chat_message',
          'header': header
        }
      })

      if (!messages.length) {
        debug('Chat.sendMessageToUsers error retrieving conversations')
        return reject(new Error('Chat.sendMessageToUsers error retrieving conversations'))
      } else {
        self.client.post(constants.endpoints.chat.sendMessage, {
          'auth_token': self.client.authToken,
          'messages': JSON.stringify(messages),
          'username': self.client.username
        }, function (err, result) {
          if (err) {
            return reject(err)
          } else if (result) {
            if (result.conversations && result.conversations) {
              results.conversations = result.conversations.map(function (convo) {
                return new Conversation(convo)
              })
              return resolve(results)
            } else {
              debug('Chat.sendMessageToUsers parse error %j', result)
            }
          }

          return reject(new Error('Chat.sendMessageToUsers parse error'))
        })
      }
    })
  }).nodeify(cb)
}

/**
 * Loads another page of conversations in the feed after the given conversation.
 *
 * This method will update client.session.conversations accordingly.
 *
 * @param {Conversation} conversation The conversation after which to load more conversations.
 * @param {function} cb
 */
Chat.prototype.loadConversationsAfter = function (conversation, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.loadConversationsAfter')

    if (!conversation || !conversation.pagination || !conversation.pagination.length) {
      return resolve([ ])
    }

    self.client.post(constants.endpoints.chat.conversations, {
      'username': self.client.username,
      'checksum': StringUtils.md5HashToHex(self.client.username),
      'offset': conversation.pagination
    }, function (err, result) {
      if (err) {
        return reject(err)
      } else if (result) {
        result = result['conversations_response']

        if (result) {
          var conversations = result.map(function (result) {
            var convo = new Conversation(result)

            self.client.session.conversations.push(convo)
            return convo
          })

          return resolve(conversations)
        }

        return reject(new Error('Chat.loadConversationsAfter parse error'))
      }
    })
  }).nodeify(cb)
}

/**
 * Loads all conversations into the current session.
 *
 * This method will update client.session.conversations accordingly.
 * @param {function} cb
 */
Chat.prototype.loadAllConversations = function (cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.loadAllConversations')

    self.client.updateSession(function (err) {
      if (err) {
        return reject(err)
      }

      var conversations = [ ]
      var last = self.client.session.conversations[self.client.session.conversations.length - 1]

      function loadPage () {
        self.loadConversationsAfter(last, function (err, convos) {
          if (err) {
            return reject(err)
          } else if (convos.length > 0) {
            conversations = conversations.concat(convos)
            last = convos[convos.length - 1]
            return loadPage()
          }

          conversations.forEach(function (convo) {
            self.client.session.conversations.push(convo)
          })
          return resolve(conversations)
        })
      }

      loadPage()
    })
  }).nodeify(cb)
}

/**
 * Loads more messages after the given message or cash transaction.
 *
 * @param {Transaction|Message} messageOrTransaction any object conforming to Pagination \b EXCEPT AN \C Conversation.
 * @warning Do not pass an Conversation object to messageOrTransaction. Doing so will throw an exception.
 * @param {function} cb
 */
Chat.prototype.loadMessagesAfterPagination = function (messageOrTransaction, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.loadMessagesAfterPagination')

    if (messageOrTransaction instanceof Conversation ||
       !messageOrTransaction.conversationIdentifier) {
      return reject(new Error('Chat.loadMessagesAfterPagination invalid param'))
    }

    if (!messageOrTransaction ||
        !messageOrTransaction.pagination ||
        !messageOrTransaction.pagination.length) {
      return resolve()
    }

    self.clients.post(constants.endpoints.chat.conversation, {
      'username': self.client.username,
      'conversation_id': messageOrTransaction.conversationIdentifier,
      'offset': messageOrTransaction.pagination
    }, function (err, result) {
      if (err) {
        return reject(err)
      } else if (result && result.conversation) {
        return resolve(new Conversation(result.conversation))
      }

      return reject(new Error('Chat.loadConversationsAfter parse error'))
    })
  }).nodeify(cb)
}

/**
 * Loads all messages in the given thread and adds them to that Conversation object.
 *
 * @param {Conversation} conversation The conversation to load completely.
 * @param {function} cb
 */
Chat.prototype.loadFullConversation = function (conversation, cb) {
  var self = this

  return new Promise(function (resolve, reject) {
    debug('Chat.loadFullConversation')

    var last = conversation.messages[conversation.messages.length - 1]

    function loadPage () {
      self.loadMessagesAfterPagination(last, function (err, convo) {
        if (err) {
          return reject(err)
        } else if (convo) {
          last = convo.messages[convo.messages.length - 1]
          conversation.addMessagesFromConversation(convo)
          return loadPage()
        }

        return resolve()
      })
    }

    loadPage()
  }).nodeify(cb)
}

/**
 * @private
 */
Chat.prototype._sendTyping = function (recipients, cb) {
  var self = this

  return self.client.post(constants.endpoints.chat.typing, {
    'recipient_usernames': JSON.stringify(recipients),
    'username': self.client.username
  }, cb)
}