module.exports = Snapchat // external var debug = require('debug')('snapchat') var phone = require('phone') var zlib = require('zlib') var Promise = require('bluebird') // utilities var constants = require('./lib/constants') var Request = require('./lib/request') var StringUtils = require('./lib/string-utils') // routes var Account = require('./routes/account') var Chat = require('./routes/chat') var Device = require('./routes/device') var Friends = require('./routes/friends') var Snaps = require('./routes/snaps') var Stories = require('./routes/stories') // models var Session = require('./models/session') /** * Snapchat Client * * @class * @param {Object} opts (currently unused) */ function Snapchat (opts) { var self = this if (!(self instanceof Snapchat)) return new Snapchat(opts) if (!opts) opts = {} debug('new snapchat client') self._account = new Account(self, opts) self._chat = new Chat(self, opts) self._device = new Device(self, opts) self._friends = new Friends(self, opts) self._snaps = new Snaps(self, opts) self._stories = new Stories(self, opts) } /** * Account routes. * * @name Snapchat#account * @property {Account} * @readonly */ Object.defineProperty(Snapchat.prototype, 'account', { get: function () { return this._account } }) /** * Chat routes. * * @name Snapchat#chat * @property {Chat} * @readonly */ Object.defineProperty(Snapchat.prototype, 'chat', { get: function () { return this._chat } }) /** * Device routes. * * @name Snapchat#device * @property {Device} * @readonly */ Object.defineProperty(Snapchat.prototype, 'device', { get: function () { return this._device } }) /** * Friend routes. * * @name Snapchat#friends * @property {Friends} * @readonly */ Object.defineProperty(Snapchat.prototype, 'friends', { get: function () { return this._friends } }) /** * Snap routes. * * @name Snapchat#snaps * @property {Snaps} * @readonly */ Object.defineProperty(Snapchat.prototype, 'snaps', { get: function () { return this._snaps } }) /** * Story routes. * * @name Snapchat#stories * @property {Stories} * @readonly */ Object.defineProperty(Snapchat.prototype, 'stories', { get: function () { return this._stories } }) /** * The username of the currently signed in (or not yet singed in) user. * (Always lowercase) * * @name Snapchat#username * @property {string} */ Object.defineProperty(Snapchat.prototype, 'username', { get: function () { var self = this return self._username } }) /** * The username of the currently signed in (or not yet singed in) user. * @note Always lowercase. * * @name Snapchat#session * @property {Session} */ Object.defineProperty(Snapchat.prototype, 'session', { get: function () { var self = this return self._session }, set: function (session) { var self = this self._session = session if (session) { self._username = session.username self._authToken = session.authToken } else { self._username = null self._authToken = null } } }) /** * The size of your device's screen. * * @name Snapchat#screenSize * @property {Object} */ Object.defineProperty(Snapchat.prototype, 'screenSize', { get: function () { var self = this return self._screenSize } }) /** * The maximum size to load videos in. * * @name Snapchat#maxVideoSize * @property {Object} */ Object.defineProperty(Snapchat.prototype, 'maxVideoSize', { get: function () { var self = this return self._maxVideoSize } }) /** * Whether or not this client is signed in. * * @name Snapchat#isSignedIn * @property {boolean} */ Object.defineProperty(Snapchat.prototype, 'isSignedIn', { get: function () { var self = this return self._googleAuthToken && self._authToken && self._username } }) /** * Used internally to sign in. * * @name Snapchat#authToken * @property {string} */ Object.defineProperty(Snapchat.prototype, 'authToken', { get: function () { var self = this return self._authToken } }) /** * Used internally to sign in. * * @name Snapchat#googleAuthToken * @property {string} */ Object.defineProperty(Snapchat.prototype, 'googleAuthToken', { get: function () { var self = this return self._googleAuthToken } }) /** * Used internally. * * @name Snapchat#deviceToken1i * @property {string} */ Object.defineProperty(Snapchat.prototype, 'deviceToken1i', { get: function () { var self = this return self._deviceToken1i } }) /** * Used internally. * * @name Snapchat#deviceToken1v * @property {string} */ Object.defineProperty(Snapchat.prototype, 'deviceToken1v', { get: function () { var self = this return self._deviceToken1v } }) /** * Used internally to sign in and trick Snapchat into thinking we're using the first party client. * * @name Snapchat#googleAttestation * @property {string} */ Object.defineProperty(Snapchat.prototype, 'googleAttestation', { get: function () { var self = this return self._googleAttestation } }) /** * Signs into Snapchat. * * A valid GMail account is necessary to trick Snapchat into thinking we're using the first party client. * * Note that username, password, gmailEmail, and gmailPassword are all optional only if * their environment variable equivalents exist. E.g., * * SNAPCHAT_USERNAME * SNAPCHAT_PASSWORD * SNAPCHAT_GMAIL_EMAIL * SNAPCHAT_GMAIL_PASSWORD * * @param {string} Optional username The Snapchat username to sign in with. * @param {string} Optional password The password to the Snapchat account to sign in with. * @param {string} Optional gmailEmail A valid GMail address. * @param {string} Optional gmailPassword The password associated with gmailEmail. * @param {function} cb */ Snapchat.prototype.signIn = function (username, password, gmailEmail, gmailPassword, cb) { var self = this if (typeof username === 'function') { cb = username username = process.env.SNAPCHAT_USERNAME password = process.env.SNAPCHAT_PASSWORD gmailEmail = process.env.SNAPCHAT_GMAIL_EMAIL gmailPassword = process.env.SNAPCHAT_GMAIL_PASSWORD } return new Promise(function (resolve, reject) { if (!(username && password && gmailEmail && gmailPassword)) { return reject(new Error('missing required login credentials')) } debug('Snapchat.signIn (username %s)', username) self._getGoogleAuthToken(gmailEmail, gmailPassword, function (err, googleAuthToken) { if (err) { debug('error getting google auth token') return reject(err) } var timestamp = StringUtils.timestamp() self._getAttestation(username, password, timestamp, function (err, attestation) { if (err) { debug('error getting attestation') return reject(err) } self._getGoogleCloudMessagingIdentifier(function (err, ptoken) { if (err) { debug('error getting google cloud messaging identifier') return reject(err) } self._getDeviceTokens(function (err, deviceTokens) { if (err) { debug('error getting device token') return reject(err) } // NOTE: this is a temporary requirement which unfortunately sends // the username and password via plaintext and relies on casper's // servers until we figure out a workaround for generating // X-Snapchat-Client-Auth if (true) { self._getClientAuthToken(username, password, timestamp, function (err, clientAuthToken) { if (err) { debug('error generating client auth token') return reject(err) } else { self.signInWithData(self._makeSignInData(googleAuthToken, attestation, ptoken, clientAuthToken, deviceTokens, timestamp), username, password) .then(resolve) .catch(reject) } }) } else { self.signInWithData(self._makeSignInData(googleAuthToken, attestation, ptoken, '', deviceTokens, timestamp), username, password) .then(resolve) .catch(reject) } }) }) }) }) }).nodeify(cb) } Snapchat.prototype._makeSignInData = function (googleAuthToken, attestation, ptoken, clientAuthToken, deviceTokens, timestamp) { return { googleAuthToken: googleAuthToken, attestation: attestation, pushToken: ptoken, clientAuthToken: clientAuthToken, deviceTokens: deviceTokens, timestamp: timestamp } } Snapchat.prototype.signInWithData = function (data, username, password, cb) { var self = this return new Promise(function (resolve, reject) { var googleAuthToken = data.googleAuthToken var attestation = data.attestation var ptoken = data.pushToken var clientAuthToken = data.clientAuthToken var deviceTokens = data.deviceTokens var timestamp = data.timestamp self._googleAuthToken = googleAuthToken self._googleAttestation = attestation self._deviceToken1i = deviceTokens[constants.core.deviceToken1i] self._deviceToken1v = deviceTokens[constants.core.deviceToken1v] var reqToken = StringUtils.hashSCString(constants.core.staticToken, timestamp) var preHash = StringUtils.getSCPreHashString(username, password, timestamp, reqToken) var deviceHash = StringUtils.hashHMacToBase64(preHash, self._deviceToken1v).substr(0, 20) var params = { 'username': username, 'password': password, 'width': constants.screen.width, 'height': constants.screen.height, 'max_video_width': constants.screen.maxVideoWidth, 'max_video_height': constants.screen.maxVideoHeight, 'application_id': 'com.snapchat.android', 'is_two_fa': 'false', 'ptoken': ptoken, 'pre_auth': '', 'sflag': 1, 'dsig': deviceHash, 'dtoken1i': self._deviceToken1i, 'attestation': self._googleAttestation, 'timestamp': timestamp } var headers = { } headers[constants.headers.clientAuthToken] = 'Bearer ' + self._googleAuthToken headers[constants.headers.clientAuth] = clientAuthToken var opts = { 'timestamp': timestamp } Request.postCustom(constants.endpoints.account.login, params, headers, null, opts, function (err, result) { if (err) { debug('Snapchat.signIn error %s', err) return reject(err) } else if (result) { self.session = new Session(self, result) return resolve(self.session) } err = new Error('Snapchat.signIn parse error') err.data = result return reject(err) }) }).nodeify(cb) } /** * Use this to restore a session that ended within the last hour. The google auth token must be re-generated every hour. * * @param {string} username Your Snapchat username. * @param {string} authToken Your Snapchat auth token. Can be retrieved from the authToken property. * @param {string} googleAuthToken Your Google auth token. Can be retrieved from the googleAuthToken property. * @param {function} cb */ Snapchat.prototype.restoreSession = function (username, authToken, googleAuthToken, cb) { var self = this debug('Snapchat.restoreSession (username %s)', username) self._username = username self._authToken = authToken self._googleAuthToken = googleAuthToken return self.updateSession(cb) } /** * Signs out. * * @param {function} cb */ Snapchat.prototype.signOut = function (cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.signOut (%s)', self.username) if (!self.isSignedIn) { return reject(new Error('signin required')) } self.post(constants.endpoints.account.logout, { 'username': self._username }, function (err, result) { if (err) { debug('Snapchat.signOut error %s', err) return reject(err) } else if (result && result.length === 0) { self._session = null self._username = null self._authToken = null self._googleAuthToken = null self._googleAttestation = null self._deviceToken1i = null self._deviceToken1v = null return resolve() } return reject(new Error('Snapchat.signOut parse error')) }) }).nodeify(cb) } /** * Updates all information in the session property. * * @param {function} cb */ Snapchat.prototype.updateSession = function (cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.updateSession') if (!self.isSignedIn) { return reject(new Error('signin required')) } self.post(constants.endpoints.update.all, { 'username': self._username, 'width': constants.screen.width, 'height': constants.screen.height, 'max_video_width': constants.screen.maxVideoWidth, 'max_video_height': constants.screen.maxVideoHeight, 'include_client_settings': 'true' }, function (err, result) { if (err) { debug('updateSession error %s', err) return reject(err) } else if (result) { self.session = new Session(self, result) return resolve(self.session) } return reject(new Error('updateSession error')) }) }).nodeify(cb) } /** * The first step in creating a new Snapchat account. * Registers an email, password, and birthday in preparation for creating a new account. * * The result passed to cb has the following keys: * - email: the email you registered with. * - snapchat_phone_number: a number you can use to verify your phone number later. * - username_suggestions: an array of available usernames for the next step. * * @param {string} email The email address to be associated with the account. * @param {string} password The password of the account to be created. * @param {string} birthday Your birthday, in the format YYYY-MM-DD. * @param {function} cb */ Snapchat.prototype.registerEmail = function (email, password, birthday, cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.registerEmail (email %s)', email) self.post(constants.endpoints.account.registration.start, { 'email': email, 'password': password, 'birthday': birthday }, function (err, result) { if (err) { debug('registerEmail error %s', err) return reject(err) } else if (result && !!result.logged) { return resolve(result) } return reject(new Error('registerEmail parse error')) }) }).nodeify(cb) } /** * The second step in creating a new Snapchat account. * Registers a username with an email that was registered in the first step. * You must call this method after successfully completing the first step in registration. * * @param {string} username The username of the account to be created, trimmed to the first 15 characters. * @param {string} registeredEmail The previously registered email address associated with the account (from the first step of registration). * @param {string} gmailEmail A valid GMail address. Required to make Snapchat think this is an official client. * @param {string} gmailPassword The password to the Google account associated with gmailEmail. * @param {function} cb */ Snapchat.prototype.registerUsername = function (username, registeredEmail, gmailEmail, gmailPassword, cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.registerUsername (username %s, email %s)', username, registeredEmail) self._getGoogleAuthToken(gmailEmail, gmailPassword, function (err, googleAuthToken) { if (err) { debug('could not retrieve google auth token') return reject(err) } self.post(constants.endpoints.account.registration.username, { 'username': registeredEmail, 'selected_username': username }, function (err, result) { if (err) { debug('registerUsername error %s', err) return reject(err) } else if (result) { self.session = new Session(self, result) self._googleAuthToken = googleAuthToken return resolve() } return reject(new Error('registerUsername parse error')) }) }) }).nodeify(cb) } /** * The third and final step in registration. * If you don't want to verify your humanity a phone number, * you can verify it by with a 'captcha' image of sorts. * * @param {string} mobile A 10-digit (+ optional country code, defaults to 1) mobile phone number to be associated with the account, in any format. i.e. +11234567890, (123) 456-7890, 1-1234567890 * @param {boolean} sms YES if you want a code sent via SMS, NO if you want to be called for verification. * @param {function} cb */ Snapchat.prototype.sendPhoneVerification = function (mobile, sms, cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.sendPhoneVerification (mobile %s, sms %d)', mobile, sms) if (!self.isSignedIn) { return reject(new Error('signin required')) } mobile = phone(mobile) var countryCode = +mobile[1] mobile = mobile.substr(2) self.post(constants.endpoints.account.registration.verifyPhone, { 'username': self._username, 'phoneNumber': mobile, 'countryCode': countryCode, 'action': 'updatePhoneNumber', 'skipConfirmation': true }, function (err, result) { if (err) { debug('sendPhoneVerification error %s', err) return reject(err) } debug('sendPhoneVerification result %j', result) return resolve(result) }) }).nodeify(cb) } /** * Verifies your phone number, completing registration. * * @warning cb is not called in this implementaiton because I haven't tested it yet. * @todo: document response, get cb working * @param {string} code The code sent to verify your number. * @param {function} cb */ Snapchat.prototype.verifyPhoneNumber = function (code, cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.verifyPhoneNumber (code %s)', code) if (!self.isSignedIn) { return reject(new Error('signin required')) } Request.post(constants.endpoints.account.registration.verifyPhone, { 'action': 'verifyPhoneNumber', 'username': self._username, 'code': code }, self._googleAuthToken, constants.core.staticToken, function (err, result) { if (err) { debug('verifyPhoneNumber error %s', err) return reject(err) } debug('verifyPhoneNumber result %j', result) return resolve(result) }) }).nodeify(cb) } /** * Downloads captcha images to verify a new account with. * cb will be called with an array of 9 Blobs representing captcha images. * * @param {function} cb */ Snapchat.prototype.getCaptcha = function (cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.getCaptcha') if (!self.isSignedIn) { return reject(new Error('signin required')) } self.post(constants.endpoints.account.registration.getCaptcha, { 'username': self._username }, function (err, body) { if (err) { debug('getCaptcha error %s', err) return reject(err) } zlib.gunzip(new Buffer(body), function (err, data) { if (err) { debug('getCaptcha gunzip error %s', err) return reject(err) } // TODO return reject(new Error('Snapchat.getCaptcha TODO')) }) }) }).nodeify(cb) } /** * Use this to 'solve' a captcha. * @warning Seems to not be working. * @todo: document response * * @param {string} solution The solution to the captcha as a binary string. If the first, second, and last images contain ghosts, the solution would be '110000001'. * @param {function} cb */ Snapchat.prototype.solveCaptcha = function (solution, cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.solveCaptcha') if (!self.isSignedIn) { return reject(new Error('signin required')) } return reject(new Error('Snapchat.solveCaptcha TODO')) }).nodeify(cb) } /** * Initializes a POST request to the Snapchat API. * * @param {string} endpoint Snapchat API endpoint * @param {Object} params Form data (will be augmented with required snapchat API params) * @param {function} cb */ Snapchat.prototype.post = function (endpoint, params, cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.post (%s)', endpoint) Request.post(endpoint, params, self._googleAuthToken, self._authToken, function (err, result) { if (err) { return reject(err) } return resolve(result) }) }).nodeify(cb) } /** * [get description] * * @param {string} endpoint * @param {function} cb */ Snapchat.prototype.get = function (endpoint, cb) { return new Promise(function (resolve, reject) { debug('Snapchat.get (%s)', endpoint) Request.get(endpoint, function (err, result) { if (err) { return reject(err) } else { return resolve(result) } }) }).nodeify(cb) } /** * internal */ Snapchat.prototype.sendEvents = function (events, snapInfo, cb) { var self = this return new Promise(function (resolve, reject) { debug('Snapchat.sendEvents') if (!self.isSignedIn) { return reject(new Error('signin required')) } events = events || { } snapInfo = snapInfo || { } self._post(constants.endpoints.update.snaps, { 'events': events, 'json': snapInfo, 'username': self._username }, function (err, result) { if (err) { debug('sendEvents error %s', err) return reject(err) } debug('sendEvents result %j', result) return resolve(result) }) }).nodeify(cb) } /** * @private */ Snapchat.prototype._getGoogleAuthToken = function (gmailEmail, gmailPassword, cb) { var encryptedGmailPassword = StringUtils.encryptGmailPassword(gmailEmail, gmailPassword) Request.postRaw({ url: 'https://android.clients.google.com/auth', form: { 'google_play_services_version': '7097038', 'device_country': 'us', 'operatorCountry': 'us', 'lang': 'en_US', 'sdk_version': '19', 'accountType': 'HOSTED_OR_GOOGLE', 'Email': gmailEmail, 'EncryptedPasswd': encryptedGmailPassword, // 'Passwd': gmailPassword, // unencrypted version 'service': 'audience:server:client_id:694893979329-l59f3phl42et9clpoo296d8raqoljl6p.apps.googleusercontent.com', 'source': 'android', 'androidId': '378c184c6070c26c', 'app': 'com.snapchat.android', 'client_sig': '49f6badb81d89a9e38d65de76f09355071bd67e7', 'callerPkg': 'com.snapchat.android', 'callerSig': '49f6badb81d89a9e38d65de76f09355071bd67e7' }, headers: { 'device': '378c184c6070c26c', 'app': 'com.snapchat.android', 'Accept-Encoding': 'gzip', 'User-Agent': 'GoogleAuth/1.4 (mako JDQ39)' } }, function (err, response, body) { if (err) { debug('_getGoogleAuthToken error %s', err) return cb(err) } else if (body) { var auth = StringUtils.matchGroup(body, /Auth=([\w\.-]+)/i, 1) if (auth) { return cb(null, auth) } } return cb('Snapchat._getGoogleAuthToken unknown error') }) } // static cache of device tokens var sDeviceToken1i = null var sDeviceToken1v = null /** * @private */ Snapchat.prototype._getDeviceTokens = function (cb) { // cache device tokens var dt1i = sDeviceToken1i var dt1v = sDeviceToken1v function completion () { var result = { } result[constants.core.deviceToken1i] = dt1i result[constants.core.deviceToken1v] = dt1v return cb(null, result) } if (dt1i && dt1v) { return completion() } else { Request.post(constants.endpoints.device.identifier, { }, null, null, function (err, result) { if (err) { debug('_getDeviceTokens error %s', err) return cb(err) } else if (result) { dt1i = result[constants.core.deviceToken1i] dt1v = result[constants.core.deviceToken1v] if (dt1i && dt1v) { sDeviceToken1i = dt1i sDeviceToken1v = dt1v return completion() } } debug('Snapchat._getDeviceTokens parse error %j', result) return cb('Snapchat._getDeviceTokens parse error') }) } } /** * ptoken value * @private */ Snapchat.prototype._getGoogleCloudMessagingIdentifier = function (cb) { var DEFAULT_TOKEN = 'ie' process.nextTick(function () { cb(null, DEFAULT_TOKEN) }) // TODO: cloud messaging identifier always returns 'Error=AUTHENTICATION_FAILED' // skipping this for now to speedup testing /* Request.postRaw({ url: 'https://android.clients.google.com/c2dm/register3', form: { 'X-google.message_id': 'google.rpc1', 'device': 4343470343591528399, 'sender': 191410808405, 'app_ver': 706, 'gcm_ver': 7097038, 'app': 'com.snapchat.android', 'iat': (new Date()).getTime(), 'cert': '49f6badb81d89a9e38d65de76f09355071bd67e7' }, headers: { 'Accept-Language': 'en', 'Accept-Locale': 'en_US', 'app': 'com.snapchat.android', 'Authorization': 'AidLogin 4343470343591528399:5885638743641649694', 'Gcm-ver': '7097038', 'Accept-Encoding': 'gzip', 'User-Agent': 'Android-GCM/1.5 (A116 _Quad KOT49H)' } }, function (err, response, body) { if (err) { return cb(err) } else if (body) { // parse token var token = StringUtils.matchGroup(body, /token=([\w\.-]+)/, 1) if (token) { return cb(null, token) } else { // debug('_getGoogleCloudMessagingIdentifier using default token %s', body) // default token return cb(null, DEFAULT_TOKEN) } } debug('_getGoogleCloudMessagingIdentifier parse error %s', body) return cb('_getGoogleCloudMessagingIdentifier error') })*/ } /** * Attestation, courtesy of casper.io * @private */ Snapchat.prototype._getAttestation = function (username, password, ts, cb) { var preHash = StringUtils.getSCPreHashString(username, password, ts, constants.endpoints.account.login) var nonce = StringUtils.sha256HashToBase64(preHash) var params = { 'nonce': nonce, 'authentication': constants.attestation.auth, 'apk_digest': constants.attestation.digest(), 'timestamp': ts } Request.postRaw({ url: constants.attestation.URLCasper, form: params }, function (err, response, result) { if (err) { return cb(err) } else if (result && +result.code === 200 && result.attestation) { return cb(null, result.attestation) } return cb('Snapchat._getAttestation unknown error') }) } /** * Client Auth Token, courtesy of casper.io * * Note that casper.io uses libscplugin.so which has been extracted from the * Android Snapchat client. * * @private */ Snapchat.prototype._getClientAuthToken = function (username, password, ts, cb) { Request.postRaw({ url: constants.attestation.URLCasperAuth, form: { username: username, password: password, timestamp: ts } }, function (err, response, result) { if (err) { return cb(err) } else if (result && +result.code === 200 && result.signature) { return cb(null, result.signature) } return cb('Snapchat._getClientAuthToken unknown error') }) }