UNPKG

vk-io-stable

Version:

Модуль для создания бота VK

2,679 lines (2,175 loc) 228 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var nodeHttps = require('https'); var nodeHttps__default = _interopDefault(nodeHttps); var nodeUtil = require('util'); var nodeUtil__default = _interopDefault(nodeUtil); var fetch = _interopDefault(require('node-fetch')); var createDebug = _interopDefault(require('debug')); var nodeUrl = _interopDefault(require('url')); var cheerio = _interopDefault(require('cheerio')); var toughCookie = _interopDefault(require('tough-cookie')); var nodeCrypto = _interopDefault(require('crypto')); var nodeFs = _interopDefault(require('fs')); var nodeStream = _interopDefault(require('stream')); var sandwichStream = require('sandwich-stream'); var middlewareIo = require('middleware-io'); var nodeHttp = _interopDefault(require('http')); var WebSocket = _interopDefault(require('ws')); /** * Creates a key and value from the keys * * @param {string[]} keys * * @return {Object} */ const keyMirror = (keys) => { const out = {}; for (const key of keys) { out[key] = key; } return out; }; /** * Returns method for execute * * @param {string} method * @param {Object} params * * @return {string} */ const getExecuteMethod = (method, params = {}) => { const options = {}; for (const [key, value] of Object.entries(params)) { options[key] = typeof value === 'object' ? String(value) : value; } return `API.${method}(${JSON.stringify(options)})`; }; /** * Returns chain for execute * * @param {Array} methods * * @return {string} */ const getChainReturn = methods => ( `return [${methods.join(',')}];` ); /** * Resolve task * * @param {Array} tasks * @param {Array} results */ const resolveExecuteTask = (tasks, result) => { let errors = 0; result.response.forEach((response, i) => { if (response !== false) { tasks[i].resolve(response); return; } tasks[i].reject(result.errors[errors]); errors += 1; }); }; /** * Returns random ID * * @return {number} */ const getRandomId = () => ( `${Math.floor(Math.random() * 1e4)}${Date.now()}` ); /** * Delay N-ms * * @param {number} delayed * * @return {Promise} */ const delay = delayed => ( new Promise(resolve => setTimeout(resolve, delayed)) ); const lt = /&lt;/g; const qt = /&gt;/g; const br = /<br>/g; const amp = /&amp;/g; const quot = /&quot;/g; /** * Decodes HTML entities * * @param {string} text * * @return {string} */ const unescapeHTML = text => ( text .replace(lt, '<') .replace(qt, '>') .replace(br, '\n') .replace(amp, '&') .replace(quot, '"') ); /** * Copies object params to new object * * @param {Object} params * @param {Array} properties * * @return {Object} */ const copyParams = (params, properties) => { const copies = {}; for (const property of properties) { copies[property] = params[property]; } return copies; }; /** * Displays deprecated message * * @param {string} message */ const showDeprecatedMessage = (message) => { // eslint-disable-next-line no-console console.log(' \u001b[31mDeprecated:\u001b[39m', message); }; const { inspect } = nodeUtil__default; class Request { /** * Constructor * * @param {string} method * @param {Object} params */ constructor(method, params = {}) { this.method = method; this.params = { ...params }; this.attempts = 0; this.promise = new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }); } /** * Returns custom tag * * @return {string} */ get [Symbol.toStringTag]() { return 'Request'; } /** * Adds attempt * * @return {number} */ addAttempt() { this.attempts += 1; return this.attempts; } /** * Returns string to execute * * @return {string} */ toString() { return getExecuteMethod(this.method, this.params); } /** * Custom inspect object * * @param {?number} depth * @param {Object} options * * @return {string} */ [inspect.custom](depth, options) { const { name } = this.constructor; const { method, params, promise } = this; const payload = { method, params, promise }; return `${options.stylize(name, 'special')} ${inspect(payload, options)}`; } } /** * General error class * * @public */ class VKError extends Error { /** * Constructor * * @param {Object} payload */ constructor({ code, message }) { super(message); this.code = code; this.message = message; this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } /** * Returns custom tag * * @return {string} */ get [Symbol.toStringTag]() { return this.constructor.name; } /** * Returns property for json * * @return {Object} */ toJSON() { const json = {}; for (const key of Object.getOwnPropertyNames(this)) { json[key] = this[key]; } return json; } } var version = "4.0.0-rc.18"; /** * VK API version * * @type {string} */ const API_VERSION = '5.95'; /** * Chat peer ID * * @type {number} */ const CHAT_PEER = 2e9; /** * Blank html redirect * * @type {string} */ const CALLBACK_BLANK = 'https://oauth.vk.com/blank.html'; /** * User-Agent for standalone auth * * @type {string} */ const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.80 Safari/537.36'; /** * Minimum time interval api with error * * @type {number} */ const MINIMUM_TIME_INTERVAL_API = 1133; /** * Default options * * @type {Object} * * @property {?string} [token] Access token * @property {Agent} [agent] HTTPS agent * @property {?string} [language] The return data language * * @property {?number} [appId] Application ID * @property {?number} [appSecret] Secret application key * * @property {?string} [login] User login (phone number or email) * @property {?string} [phone] User phone number * @property {?string} [password] User password * * @property {?number} [authScope] List of permissions * @property {?number} [authTimeout] Wait time for one auth request * * @property {string} [apiMode] Query mode (sequential|parallel|parallel_selected) * @property {number} [apiWait] Time to wait before re-querying * @property {number} [apiLimit] Requests per second * @property {string} [apiBaseUrl] Base API URL * @property {number} [apiTimeout] Wait time for one request * @property {number} [apiHeaders] Headers sent to the API * @property {number} [apiAttempts] The number of retries at calling * @property {number} [apiExecuteCount] Number of requests per execute * @property {Array} [apiExecuteMethods] Methods for call execute (apiMode=parallel_selected) * * @property {number} [uploadTimeout] Wait time for one request * * @property {number} [pollingWait] Time to wait before re-querying * @property {number} [pollingGroupId] Group ID for polling * @property {number} [pollingAttempts] The number of retries at calling * * @property {?string} [webhookSecret] Webhook secret key * @property {?string} [webhookConfirmation] Webhook confirmation key * * @property {number} [collectAttempts] The number of retries at calling */ const defaultOptions = { token: null, agent: null, language: null, appId: null, appSecret: null, login: null, phone: null, password: null, authScope: 'all', authTimeout: 10e3, apiMode: 'sequential', apiWait: 3e3, apiLimit: 3, apiBaseUrl: 'https://api.vk.com/method', apiAttempts: 3, apiTimeout: 10e3, apiHeaders: { 'User-Agent': `vk-io/${version} (+https://github.com/negezor/vk-io)` }, apiExecuteCount: 25, apiExecuteMethods: ['messages.send'], uploadTimeout: 20e3, pollingWait: 3e3, pollingAttempts: 3, pollingGroupId: null, webhookSecret: null, webhookConfirmation: null, collectAttempts: 3 }; /** * The attachment types * * @type {Object} */ const attachmentTypes = { AUDIO: 'audio', AUDIO_MESSAGE: 'audio_message', GRAFFITI: 'graffiti', DOCUMENT: 'doc', GIFT: 'gift', LINK: 'link', MARKET_ALBUM: 'market_album', MARKET: 'market', PHOTO: 'photo', STICKER: 'sticker', VIDEO: 'video', WALL_REPLY: 'wall_reply', WALL: 'wall', POLL: 'poll' }; /** * Default extensions for attachments * * @type {Object} */ const defaultExtensions = { photo: 'jpg', video: 'mp4', audio: 'mp3', graffiti: 'png', audioMessage: 'ogg' }; /** * Default content type for attachments * * @type {Object} */ const defaultContentTypes = { photo: 'image/jpeg', video: 'video/mp4', audio: 'audio/mp3', graffiti: 'image/png', audioMessage: 'audio/ogg' }; /** * Sources of captcha * * @type {Object} */ const captchaTypes = keyMirror([ 'API', 'DIRECT_AUTH', 'IMPLICIT_FLOW_AUTH', 'ACCOUNT_VERIFICATION' ]); /** * Message source * * @type {Object} */ const messageSources = { USER: 'user', CHAT: 'chat', GROUP: 'group', EMAIL: 'email' }; /** * Resource types * * @type {Object} */ const resourceTypes = { USER: 'user', GROUP: 'group', APPLICATION: 'application' }; /** * API error codes * * @type {Object} */ const apiErrors = { UNKNOWN_ERROR: 1, APP_SWITCHED_OFF: 2, UNKNOWN_METHOD: 3, INVALID_SIGNATURE: 4, AUTH_FAILURE: 5, TOO_MANY_REQUESTS: 6, SCOPE_NEEDED: 7, INCORRECT_REQUEST: 8, TOO_MANY_SIMILAR_ACTIONS: 9, INTERNAL_ERROR: 10, RESPONSE_SIZE_TOO_BIG: 13, CAPTCHA_REQUIRED: 14, ACCESS_DENIED: 15, USER_VALIDATION_REQUIRED: 17, PAGE_BLOCKED: 18, STANDALONE_ONLY: 20, STANDALONE_AND_OPEN_API_ONLY: 21, METHOD_DISABLED: 23, CONFIRMATION_REQUIRED: 24, GROUP_TOKEN_NOT_VALID: 27, APP_TOKEN_NOT_VALID: 28, METHOD_CALL_LIMIT: 29, PROFILE_IS_PRIVATE: 30, WRONG_PARAMETER: 100, INVALID_APPLICATION_ID: 101, LIMIT_ENTRY_EXHAUSTED: 103, INCORRECT_USER_ID: 113, INVALID_TIMESTAMP: 150, ALBUM_ACCESS_DENIED: 200, AUDIO_ACCESS_DENIED: 201, GROUP_ACCESS_DENIED: 203, ALBUM_OVERFLOW: 300, PAYMENTS_DISABLED: 500, COMMERCIAL_ACCESS_DENIED: 600, COMMERCIAL_ERROR: 603, BLACKLISTED_USER: 900, MESSAGE_COMMUNITY_BLOCKED_BY_USER: 901, MESSAGE_BLOCKED_BY_USER_PRIVACY: 902, UNABLE_TO_EDIT_MESSAGE_AFTER_DAY: 909, MESSAGE_CANNOT_EDIT_IS_TOO_LONG: 910, KEYBOARD_FORMAT_IS_INVALID: 911, CHAT_BOT_FEATURE: 912, TOO_MANY_FORWARDED_MESSAGES: 913, MESSAGE_TOO_LONG: 914, NO_ACCESS_TO_CONVERSATION: 917, CANNOT_EDIT_THIS_TYPE_MESSAGE: 920, UNABLE_TO_FORWARD_MESSAGES: 921, UNABLE_TO_DELETE_MESSAGE_FOR_RECIPIENTS: 924, NOT_ADMIN_CHAT: 925, COMMUNITY_CANNOT_INTERACT_WITH_THIS_PEER: 932, CONTACT_NOT_FOUND: 936 }; /** * Auth error codes * * @type {Object} */ const authErrors = keyMirror([ 'PAGE_BLOCKED', 'INVALID_PHONE_NUMBER', 'AUTHORIZATION_FAILED', 'FAILED_PASSED_CAPTCHA', 'FAILED_PASSED_TWO_FACTOR', ]); /** * Upload error codes * * @type {Object} */ const uploadErrors = keyMirror([ 'MISSING_PARAMETERS', 'NO_FILES_TO_UPLOAD', 'EXCEEDED_MAX_FILES', 'UNSUPPORTED_SOURCE_TYPE' ]); /** * Updates error codes * * @type {Object} */ const updatesErrors = keyMirror([ 'NEED_RESTART', 'POLLING_REQUEST_FAILED' ]); /** * Collect error codes * * @type {Object} */ const collectErrors = keyMirror([ 'EXECUTE_ERROR' ]); /** * Snippets error codes * * @type {Object} */ const snippetsErrors = keyMirror([ 'INVALID_URL', 'INVALID_RESOURCE', 'RESOURCE_NOT_FOUND' ]); /** * Snippets error codes * * @type {Object} */ const sharedErrors = keyMirror([ 'MISSING_CAPTCHA_HANDLER', 'MISSING_TWO_FACTOR_HANDLER' ]); /** * Updates sources * * @type {Object} */ const updatesSources = keyMirror([ 'POLLING', 'WEBHOOK' ]); /** * List of user permissions and their bit mask * * @type {Map} */ const userScopes = new Map([ ['notify', 1], ['friends', 2], ['photos', 4], ['audio', 8], ['video', 16], ['pages', 128], ['link', 256], ['status', 1024], ['notes', 2048], ['messages', 4096], ['wall', 8192], ['ads', 32768], ['offline', 65536], ['docs', 131072], ['groups', 262144], ['notifications', 524288], ['stats', 1048576], ['email', 4194304], ['market', 134217728] ]); /** * List of group permissions and their bit mask * * @type {Map} */ const groupScopes = new Map([ ['stories', 1], ['photos', 4], // ['app_widget', 64], ['messages', 4096], ['docs', 131072], ['manage', 262144] ]); /** * VK Platforms * * @type {Map} */ const platforms = new Map([ [1, 'mobile'], [2, 'iphone'], [3, 'ipad'], [4, 'android'], [5, 'wphone'], [6, 'windows'], [7, 'web'], [8, 'standalone'] ]); /** * Parse attachments with RegExp * * @type {RegExp} */ const parseAttachment = /(photo|video|audio|doc|audio_message|graffiti|wall|market|poll|gift)([-\d]+)_(\d+)_?(\w+)?/; /** * Parse resource with RegExp * * @type {RegExp} */ const parseResource = /(id|club|public|albums|tag|app(?:lication))([-\d]+)/; /** * Parse owner resource with RegExp * * @type {RegExp} */ const parseOwnerResource = /(album|topic|wall|page|videos)([-\d]+)_(\d+)/; /** * Inspect custom data * * @type {Symbol} */ const inspectCustomData = Symbol('inspectCustomData'); const { CAPTCHA_REQUIRED, USER_VALIDATION_REQUIRED, CONFIRMATION_REQUIRED } = apiErrors; class APIError extends VKError { /** * Constructor * * @param {Object} payload */ constructor(payload) { const code = Number(payload.error_code); const message = `Code №${code} - ${payload.error_msg}`; super({ code, message }); this.params = payload.request_params; if (code === CAPTCHA_REQUIRED) { this.captchaSid = Number(payload.captcha_sid); this.captchaImg = payload.captcha_img; } else if (code === USER_VALIDATION_REQUIRED) { this.redirectUri = payload.redirect_uri; } else if (code === CONFIRMATION_REQUIRED) { this.confirmationText = payload.confirmation_text; } } } const { DEBUG = '' } = process.env; const isDebug = DEBUG.includes('vk-io:auth'); class AuthError extends VKError { /** * Constructor * * @param {Object} payload */ constructor({ message, code, pageHtml = null }) { super({ message, code }); this.pageHtml = isDebug ? pageHtml : null; } } class UploadError extends VKError {} class CollectError extends VKError { /** * Constructor * * @param {Object} payload */ constructor({ message, code, errors }) { super({ message, code }); this.errors = errors; } } class UpdatesError extends VKError {} class ExecuteError extends VKError { /** * Constructor * * @param {Object} payload */ constructor(payload) { const code = Number(payload.error_code); const message = `Code №${code} - ${payload.error_msg}`; super({ code, message }); this.method = payload.method; } } class SnippetsError extends VKError {} class StreamingRuleError extends VKError { /** * Constructor * * @param {Object} payload */ constructor({ message, error_code: code }) { super({ message, code }); } } const { URL } = nodeUrl; /** * Returns the entire permission bit mask * * @return {number} */ const getAllUsersPermissions = () => ( Array.from(userScopes.values()).reduce((previous, current) => ( previous + current ), 0) ); /** * Returns the entire permission bit mask * * @return {number} */ const getAllGroupsPermissions = () => ( Array.from(groupScopes.values()).reduce((previous, current) => ( previous + current ), 0) ); /** * Returns the bit mask of the user permission by name * * @param {Array|string} scope * * @return {number} */ const getUsersPermissionsByName = (scope) => { if (!Array.isArray(scope)) { scope = scope.split(/,\s{0,}/); } let bitMask = 0; for (const name of scope) { if (userScopes.has(name)) { bitMask += userScopes.get(name); } } return bitMask; }; /** * Returns the bit mask of the group permission by name * * @param {Array|string} scope * * @return {number} */ const getGroupsPermissionsByName = (scope) => { if (!Array.isArray(scope)) { scope = scope.split(/,\s{0,}/); } let bitMask = 0; for (const name of scope) { if (groupScopes.has(name)) { bitMask += groupScopes.get(name); } } return bitMask; }; /** * Parse form * * @param {Cheerio} $ * * @return {Object} */ const parseFormField = ($) => { const $form = $('form[action][method]'); const fields = {}; for (const { name, value } of $form.serializeArray()) { fields[name] = value; } return { action: $form.attr('action'), fields }; }; /** * Returns full URL use Response * * @param {string} action * @param {Response} response * * @type {URL} */ const getFullURL = (action, { url }) => { if (action.startsWith('https://')) { return new URL(action); } const { protocol, host } = new URL(url); return new URL(action, `${protocol}//${host}`); }; const { promisify } = nodeUtil__default; const debug = createDebug('vk-io:util:fetch-cookie'); const REDIRECT_CODES = [303, 301, 302]; const { CookieJar } = toughCookie; const USER_AGENT_RE = /^User-Agent$/i; const findUserAgent = (headers) => { if (!headers) { return null; } const key = Object.keys(headers) .find(header => USER_AGENT_RE.test(header)); if (!key) { return null; } return headers[key]; }; const fetchCookieDecorator = (jar = new CookieJar()) => { const setCookie = promisify(jar.setCookie).bind(jar); const getCookieString = promisify(jar.getCookieString).bind(jar); return async function fetchCookie(url, options = {}) { const previousCookie = await getCookieString(url); const { headers = {} } = options; if (previousCookie) { headers.cookie = previousCookie; } debug('fetch url %s', url); const response = await fetch(url, { ...options, headers }); const { 'set-cookie': cookies = [] } = response.headers.raw(); if (cookies.length === 0) { return response; } await Promise.all(cookies.map(cookie => ( setCookie(cookie, response.url) ))); return response; }; }; const fetchCookieFollowRedirectsDecorator = (jar) => { const fetchCookie = fetchCookieDecorator(jar); return async function fetchCookieFollowRedirects(url, options = {}) { const response = await fetchCookie(url, { ...options, redirect: 'manual' }); const isRedirect = REDIRECT_CODES.includes(response.status); if (isRedirect && options.redirect !== 'manual' && options.follow !== 0) { debug('Redirect to', response.headers.get('location')); let follow; if (options.follow) { follow = options.follow - 1; } const userAgent = findUserAgent(options.headers); const headers = userAgent ? { 'User-Agent': userAgent } : {}; const redirectResponse = await fetchCookieFollowRedirects(response.headers.get('location'), { method: 'GET', body: null, headers, follow }); return redirectResponse; } return response; }; }; const { load: cheerioLoad } = cheerio; const { URL: URL$1, URLSearchParams } = nodeUrl; const debug$1 = createDebug('vk-io:auth:account-verification'); const { INVALID_PHONE_NUMBER, AUTHORIZATION_FAILED, FAILED_PASSED_CAPTCHA, FAILED_PASSED_TWO_FACTOR } = authErrors; /** * Two-factor auth check action * * @type {string} */ const ACTION_AUTH_CODE = 'act=authcheck'; /** * Phone number check action * * @type {string} */ const ACTION_SECURITY_CODE = 'act=security'; /** * Bind a phone to a page * * @type {string} */ const ACTION_VALIDATE = 'act=validate'; /** * Bind a phone to a page action * * @type {string} */ const ACTION_CAPTCHA = 'act=captcha'; /** * Number of two-factorial attempts * * @type {number} */ const TWO_FACTOR_ATTEMPTS = 3; class AccountVerification { /** * Constructor * * @param {VK} vk */ constructor(vk) { this.vk = vk; const { agent, login, phone } = vk.options; this.login = login; this.phone = phone; this.agent = agent; this.jar = new CookieJar(); this.fetchCookie = fetchCookieFollowRedirectsDecorator(this.jar); this.captchaValidate = null; this.captchaAttempts = 0; this.twoFactorValidate = null; this.twoFactorAttempts = 0; } /** * Returns custom tag * * @return {string} */ get [Symbol.toStringTag]() { return 'AccountVerification'; } /** * Executes the HTTP request * * @param {string} url * @param {Object} options * * @return {Promise<Response>} */ fetch(url, options = {}) { const { agent } = this; const { headers = {} } = options; return this.fetchCookie(url, { ...options, agent, timeout: this.vk.options.authTimeout, compress: false, headers: { ...headers, 'User-Agent': DESKTOP_USER_AGENT } }); } /** * Runs authorization * * @return {Promise<Object>} */ // eslint-disable-next-line consistent-return async run(redirectUri) { let response = await this.fetch(redirectUri, { method: 'GET' }); const isProcessed = true; while (isProcessed) { const { url } = response; if (url.includes(CALLBACK_BLANK)) { let { hash } = new URL$1(response.url); if (hash.startsWith('#')) { hash = hash.substring(1); } const params = new URLSearchParams(hash); if (params.has('error')) { throw new AuthError({ message: `Failed passed grant access: ${params.get('error_description') || 'Unknown error'}`, code: AUTHORIZATION_FAILED }); } const user = params.get('user_id'); return { user: user !== null ? Number(user) : null, token: params.get('access_token') }; } const $ = cheerioLoad(await response.text()); if (url.includes(ACTION_AUTH_CODE)) { response = await this.processTwoFactorForm(response, $); continue; } if (url.includes(ACTION_SECURITY_CODE)) { response = await this.processSecurityForm(response, $); continue; } if (url.includes(ACTION_VALIDATE)) { response = await this.processValidateForm(response, $); continue; } if (url.includes(ACTION_CAPTCHA)) { response = await this.processCaptchaForm(response, $); continue; } throw new AuthError({ message: 'Account verification failed', code: AUTHORIZATION_FAILED }); } } /** * Process two-factor form * * @param {Response} response * @param {Cheerio} $ * * @return {Promise<Response>} */ async processTwoFactorForm(response, $) { debug$1('process two-factor handle'); if (this.twoFactorValidate !== null) { this.twoFactorValidate.reject(new AuthError({ message: 'Incorrect two-factor code', code: FAILED_PASSED_TWO_FACTOR, pageHtml: $.html() })); this.twoFactorAttempts += 1; } if (this.twoFactorAttempts >= TWO_FACTOR_ATTEMPTS) { throw new AuthError({ message: 'Failed passed two-factor authentication', code: FAILED_PASSED_TWO_FACTOR }); } const { action, fields } = parseFormField($); const { code, validate } = await this.vk.callbackService.processingTwoFactor({}); fields.code = code; try { const url = getFullURL(action, response); response = await this.fetch(url, { method: 'POST', body: new URLSearchParams(fields) }); return response; } catch (error) { validate.reject(error); throw error; } } /** * Process security form * * @param {Response} response * @param {Cheerio} $ * * @return {Promise<Response>} */ async processSecurityForm(response, $) { debug$1('process security form'); const { login, phone } = this; let number; if (phone !== null) { number = phone; } else if (login !== null && !login.includes('@')) { number = login; } else { throw new AuthError({ message: 'Missing phone number in the phone or login field', code: INVALID_PHONE_NUMBER }); } if (typeof number === 'string') { number = number.trim().replace(/^(\+|00)/, ''); } number = String(number); const $field = $('.field_prefix'); const prefix = $field.first().text().trim().replace('+', '').length; const postfix = $field.last().text().trim().length; const { action, fields } = parseFormField($); fields.code = number.slice(prefix, number.length - postfix); const url = getFullURL(action, response); response = await this.fetch(url, { method: 'POST', body: new URLSearchParams(fields) }); if (response.url.includes(ACTION_SECURITY_CODE)) { throw new AuthError({ message: 'Invalid phone number', code: INVALID_PHONE_NUMBER }); } return response; } /** * Process validation form * * @param {Response} response * @param {Cheerio} $ * * @return {Promise<Response>} */ processValidateForm(response, $) { const href = $('#activation_wrap a').attr('href'); const url = getFullURL(href, response); return this.fetch(url, { method: 'GET' }); } /** * Process captcha form * * @param {Response} response * @param {Cheerio} $ * * @return {Promise<Response>} */ async processCaptchaForm(response, $) { if (this.captchaValidate !== null) { this.captchaValidate.reject(new AuthError({ message: 'Incorrect captcha code', code: FAILED_PASSED_CAPTCHA })); this.captchaValidate = null; this.captchaAttempts += 1; } const { action, fields } = parseFormField($); const src = $('.captcha_img').attr('src'); const { key, validate } = await this.vk.callbackService.processingCaptcha({ type: captchaTypes.ACCOUNT_VERIFICATION, sid: fields.captcha_sid, src }); this.captchaValidate = validate; fields.captcha_key = key; const url = getFullURL(action, response); url.searchParams.set('utf8', 1); const pageResponse = await this.fetch(url, { method: 'POST', body: new URLSearchParams(fields) }); return pageResponse; } } function sequential(next) { this.callMethod(this.queue.shift()); next(); } async function parallel(next) { const { queue } = this; if (queue[0].method.startsWith('execute')) { sequential.call(this, next); return; } // Wait next event loop, saves one request or more await delay(0); const { apiExecuteCount } = this.vk.options; const tasks = []; const chain = []; for (let i = 0; i < queue.length; i += 1) { if (queue[i].method.startsWith('execute')) { continue; } const request = queue.splice(i, 1)[0]; i -= 1; tasks.push(request); chain.push(String(request)); if (tasks.length >= apiExecuteCount) { break; } } try { const request = new Request('execute', { code: getChainReturn(chain) }); this.callMethod(request); next(); resolveExecuteTask(tasks, await request.promise); } catch (error) { for (const task of tasks) { task.reject(error); } } } async function parallelSelected(next) { const { apiExecuteMethods, apiExecuteCount } = this.vk.options; const { queue } = this; if (!apiExecuteMethods.includes(queue[0].method)) { sequential.call(this, next); return; } // Wait next event loop, saves one request or more await delay(0); const tasks = []; const chain = []; for (let i = 0; i < queue.length; i += 1) { if (!apiExecuteMethods.includes(queue[i].method)) { continue; } const request = queue.splice(i, 1)[0]; i -= 1; tasks.push(request); chain.push(String(request)); if (tasks.length >= apiExecuteCount) { break; } } if (tasks.length === 0) { sequential.call(this, next); return; } try { const request = new Request('execute', { code: getChainReturn(chain) }); this.callMethod(request); next(); resolveExecuteTask(tasks, await request.promise); } catch (error) { for (const task of tasks) { task.reject(error); } } } const { inspect: inspect$1 } = nodeUtil__default; const { URLSearchParams: URLSearchParams$1 } = nodeUrl; const { CAPTCHA_REQUIRED: CAPTCHA_REQUIRED$1, TOO_MANY_REQUESTS, USER_VALIDATION_REQUIRED: USER_VALIDATION_REQUIRED$1 } = apiErrors; const debug$2 = createDebug('vk-io:api'); const requestHandlers = { sequential, parallel, parallel_selected: parallelSelected }; /** * Returns request handler * * @param {string} mode * * @return {Function} */ const getRequestHandler = (mode = 'sequential') => { const handler = requestHandlers[mode]; if (!handler) { throw new VKError({ message: 'Unsuported api mode' }); } return handler; }; const groupMethods = [ 'account', 'ads', 'appWidgets', 'apps', 'audio', 'auth', 'board', 'database', 'docs', 'fave', 'friends', 'gifts', 'groups', 'leads', 'leadForms', 'likes', 'market', 'messages', 'newsfeed', 'notes', 'notifications', 'orders', 'pages', 'photos', 'places', 'polls', 'podcasts', 'prettyCards', 'search', 'secure', 'stats', 'status', 'storage', 'stories', 'streaming', 'users', 'utils', 'video', 'wall', 'widgets' ]; /** * Working with API methods * * @public */ class API { /** * Constructor * * @param {VK} vk */ constructor(vk) { this.vk = vk; this.queue = []; this.started = false; this.suspended = false; for (const group of groupMethods) { const isMessagesGroup = group === 'messages'; /** * NOTE: Optimization for other methods * * Instead of checking everywhere the presence of a property in an object * The check is only for the messages group * Since it is necessary to change the behavior of the sending method */ this[group] = new Proxy( isMessagesGroup ? { send: (params = {}) => { if (!('random_id' in params)) { params = { ...params, random_id: getRandomId() }; } return this.enqueue('messages.send', params); } } : {}, { get: isMessagesGroup ? (obj, prop) => obj[prop] || ( params => ( this.enqueue(`${group}.${prop}`, params) ) ) : (obj, prop) => params => ( this.enqueue(`${group}.${prop}`, params) ) } ); } } /** * Returns custom tag * * @return {string} */ get [Symbol.toStringTag]() { return 'API'; } /** * Returns the current used API version * * @return {string} */ get API_VERSION() { return API_VERSION; } /** * Call execute method * * @param {Object} params * * @return {Promise<Object>} */ execute(params) { return this.enqueue('execute', params); } /** * Call execute procedure * * @param {string} name * @param {Object} params * * @return {Promise<Object>} */ procedure(name, params) { return this.enqueue(`execute.${name}`, params); } /** * Call raw method * * @param {string} method * @param {Object} params * * @return {Promise<Object>} */ call(method, params) { return this.enqueue(method, params); } /** * Adds request for queue * * @param {Request} request * * @return {Promise<Object>} */ callWithRequest(request) { this.queue.push(request); this.worker(); return request.promise; } /** * Adds method to queue * * @param {string} method * @param {Object} params * * @return {Promise<Object>} */ enqueue(method, params) { const request = new Request(method, params); return this.callWithRequest(request); } /** * Adds an element to the beginning of the queue * * @param {Request} request */ requeue(request) { this.queue.unshift(request); this.worker(); } /** * Running queue */ worker() { if (this.started) { return; } this.started = true; const { apiLimit, apiMode } = this.vk.options; const handler = getRequestHandler(apiMode); const interval = Math.round(MINIMUM_TIME_INTERVAL_API / apiLimit); const work = () => { if (this.queue.length === 0 || this.suspended) { this.started = false; return; } handler.call(this, () => { setTimeout(work, interval); }); }; work(); } /** * Calls the api method * * @param {Request} request */ async callMethod(request) { const { options } = this.vk; const { method } = request; const params = { access_token: options.token, v: API_VERSION, ...request.params }; if (options.language !== null) { params.lang = options.language; } debug$2(`http --> ${method}`); const startTime = Date.now(); let response; try { response = await fetch(`${options.apiBaseUrl}/${method}`, { method: 'POST', compress: false, agent: options.agent, timeout: options.apiTimeout, headers: { ...options.apiHeaders, connection: 'keep-alive' }, body: new URLSearchParams$1(params) }); response = await response.json(); } catch (error) { if (request.addAttempt() <= options.apiAttempts) { await delay(options.apiWait); debug$2(`Request ${method} restarted ${request.attempts} times`); this.requeue(request); return; } if ('captchaValidate' in request) { request.captchaValidate.reject(error); } request.reject(error); return; } const endTime = (Date.now() - startTime).toLocaleString(); debug$2(`http <-- ${method} ${endTime}ms`); if ('error' in response) { this.handleError(request, new APIError(response.error)); return; } if ('captchaValidate' in request) { request.captchaValidate.resolve(); } if (method.startsWith('execute')) { request.resolve({ response: response.response, errors: (response.execute_errors || []).map(error => ( new ExecuteError(error) )) }); return; } request.resolve( response.response !== undefined ? response.response : response ); } /** * Error API handler * * @param {Request} request * @param {Object} error */ async handleError(request, error) { const { code } = error; if (code === TOO_MANY_REQUESTS) { if (this.suspended) { this.requeue(request); return; } this.suspended = true; await delay((MINIMUM_TIME_INTERVAL_API / this.vk.options.apiLimit) + 50); this.suspended = false; this.requeue(request); return; } if ('captchaValidate' in request) { request.captchaValidate.reject(error); } if (code === USER_VALIDATION_REQUIRED$1) { if (this.suspended) { this.requeue(request); } this.suspended = true; try { const verification = new AccountVerification(this.vk); const { token } = await verification.run(error.redirectUri); debug$2('Account verification passed'); this.vk.token = token; this.suspended = false; this.requeue(request); } catch (verificationError) { debug$2('Account verification error', verificationError); request.reject(error); await delay(15e3); this.suspended = false; this.worker(); } return; } if (code !== CAPTCHA_REQUIRED$1 || !this.vk.callbackService.hasCaptchaHandler) { request.reject(error); return; } try { const { captchaSid } = error; const { key, validate } = await this.vk.callbackService.processingCaptcha({ type: captchaTypes.API, src: error.captchaImg, sid: captchaSid, request }); request.captchaValidate = validate; request.params.captcha_sid = captchaSid; request.params.captcha_key = key; this.requeue(request); } catch (e) { request.reject(e); } } /** * Custom inspect object * * @param {?number} depth * @param {Object} options * * @return {string} */ [inspect$1.custom](depth, options) { const { name } = this.constructor; const { started, queue } = this; const payload = { started, queue }; return `${options.stylize(name, 'special')} ${inspect$1(payload, options)}`; } } const { load: cheerioLoad$1 } = cheerio; const { URL: URL$2, URLSearchParams: URLSearchParams$2 } = nodeUrl; const debug$3 = createDebug('vk-io:auth:direct'); const { INVALID_PHONE_NUMBER: INVALID_PHONE_NUMBER$1, AUTHORIZATION_FAILED: AUTHORIZATION_FAILED$1, FAILED_PASSED_CAPTCHA: FAILED_PASSED_CAPTCHA$1, FAILED_PASSED_TWO_FACTOR: FAILED_PASSED_TWO_FACTOR$1 } = authErrors; /** * Number of two-factorial attempts * * @type {number} */ const TWO_FACTOR_ATTEMPTS$1 = 3; /** * Number of captcha attempts * * @type {number} */ const CAPTCHA_ATTEMPTS = 3; /** * Phone number check action * * @type {string} */ const ACTION_SECURITY_CODE$1 = 'act=security'; class DirectAuth { /** * Constructor * * @param {VK} vk * @param {Object} options */ constructor(vk, { appId = vk.options.appId, appSecret = vk.options.appSecret, login = vk.options.login, phone = vk.options.phone, password = vk.options.password, scope = vk.options.authScope, agent = vk.options.agent, timeout = vk.options.authTimeout } = {}) { this.vk = vk; this.appId = appId; this.appSecret = appSecret; this.login = login; this.phone = phone; this.password = password; this.agent = agent; this.scope = scope; this.timeout = timeout; this.started = false; this.captchaValidate = null; this.captchaAttempts = 0; this.twoFactorValidate = null; this.twoFactorAttempts = 0; } /** * Returns custom tag * * @return {string} */ get [Symbol.toStringTag]() { return 'DirectAuth'; } /** * Executes the HTTP request * * @param {string} url * @param {Object} options * * @return {Promise<Response>} */ fetch(url, options = {}) { const { agent, timeout } = this; const { headers = {} } = options; return this.fetchCookie(url, { ...options, agent, timeout, compress: false, headers: { ...headers, 'User-Agent': DESKTOP_USER_AGENT } }); } /** * Returns permission page * * @param {Object} query * * @return {Response} */ getPermissionsPage(query = {}) { let { scope } = this; if (scope === 'all' || scope === null) { scope = getAllUsersPermissions(); } else if (typeof scope !== 'number') { scope = getUsersPermissionsByName(scope); } debug$3('auth scope %s', scope); const { appId, appSecret, login, phone, password } = this; const params = new URLSearchParams$2({ ...query, username: login || phone, grant_type: 'password', client_secret: appSecret, '2fa_supported': this.vk.callbackService.hasTwoFactorHandler ? 1 : 0, v: API_VERSION, client_id: appId, password, scope }); const url = new URL$2(`https://oauth.vk.com/token?${params}`); return this.fetch(url, { method: 'GET' }); } /** * Runs authorization * * @return {Promise<Object>} */ // eslint-disable-next-line consistent-return async run() { if (this.started) { throw new AuthError({ message: 'Authorization already started!', code: AUTHORIZATION_FAILED$1 }); } this.started = true; this.fetchCookie = fetchCookieFollowRedirectsDecorator(); let response = await this.getPermissionsPage(); let text; const isProcessed = true; while (isProcessed) { text = await response.text(); let isJSON = true; try { text = JSON.parse(text); } catch (e) { isJSON = false; } if (isJSON) { if ('access_token' in text) { const { email = null, user_id: user = null, expires_in: expires = null, access_token: token, } = text; return { email, user: user !== null ? Number(user) : null, token, expires: expires !== null ? Number(expires) : null }; } if ('error' in text) { if (text.error === 'invalid_client') { throw new AuthError({ message: `Invalid client (${text.error_description})`, code: AUTHORIZATION_FAILED$1 }); } if (text.error === 'need_captcha') { response = await this.processCaptcha(text); continue; } if (text.error === 'need_validation') { if ('validation_type' in text) { response = await this.processTwoFactor(text); continue; } const $ = cheerioLoad$1(text); response = this.processSecurityForm(response, $); continue; } throw new AuthError({ message: 'Unsupported type validation', code: AUTHORIZATION_FAILED$1 }); } } throw new AuthError({ message: 'Authorization failed', code: AUTHORIZATION_FAILED$1 }); } } /** * Process captcha * * @param {Object} payload * * @return {Response} */ async processCaptcha({ captcha_sid: sid, captcha_img: src }) { debug$3('captcha process'); if (this.captchaValidate !== null) { this.captchaValidate.reject(new AuthError({ message: 'Incorrect captcha code', code: FAILED_PASSED_CAPTCHA$1 })); this.captchaValidate = null; this.captchaAttempts += 1; } if (this.captchaAttempts >= CAPTCHA_ATTEMPTS) { throw new AuthError({ message: 'Maximum attempts passage captcha', code: FAILED_PASSED_CAPTCHA$1 }); } const { key, validate } = await this.vk.callbackService.processingCaptcha({ type: captchaTypes.DIRECT_AUTH, sid, src }); this.captchaValidate = validate; const response = await this.getPermissionsPage({ captcha_sid: sid, captcha_key: key }); return response; } /** * Process two-factor * * @param {Object} response * * @return {Promise<Response>} */ async processTwoFactor({ validation_type: validationType, phone_mask: phoneMask }) { debug$3('process two-factor handle'); if (this.twoFactorValidate !== null) { this.twoFactorValidate.reject(new AuthError({ message: 'Incorrect two-factor code', code: FAILED_PASSED_TWO_FACTOR$1 })); this.twoFactorValidate = null; this.twoFactorAttempts += 1; } if (this.twoFactorAttempts >= TWO_FACTOR_ATTEMPTS$1) { throw new AuthError({ message: 'Failed passed two-factor authentication', code: FAILED_PASSED_TWO_FACTOR$1 }); } const { code, validate } = await this.vk.callbackService.processingTwoFactor({ phoneMask, type: validationType === '2fa_app' ? 'app' : 'sms' }); this.twoFactorValidate = validate; const response = await this.getPermissionsPage({ code }); return response; } /** * Process security form * * @param {Response} response * @param {Cheerio} $ * * @return {Promise<Response>} */ async processSecurityForm(response, $) { debug$3('process security form'); const { login, phone } = this; let number; if (phone !== null) { number = phone; } else if (login !== null && !login.includes('@')) { number = login; } else { throw new AuthError({ message: 'Missing phone number in the phone or login field', code: INVALID_PHONE_NUMBER$1 }); } if (typeof number === 'string') { number = number.trim().replace(/^(\+|00)/, ''); } number = String(number); const $field = $('.field_prefix'); const prefix = $field.first().text().trim().replace('+', '').length; const postfix = $field.last().text().trim().length; const { action, fields } = parseFormField($); fields.code = number.slice(prefix, number.length - postfix); const url = getFullURL(action, response); response = await this.fetch(url, { method: 'POST', body: new URLSearchParams$2(fields) }); if (response.url.includes(ACTION_SECURITY_CODE$1)) { throw new AuthError({ message: 'Invalid phone number', code: INVALID_PHONE_NUMBER$1 }); } return response; } } const { load: cheerioLoad$2 } = cheerio; const { URL: URL$3, URLSearchParams: URLSearchParams$3 } = nodeUrl; const { promisify: promisify$1 } = nodeUtil__default; const debug$4 = createDebug('vk-io:auth:implicit-flow'); const { PAGE_BLOCKED, INVALID_PHONE_NUMBER: INVALID_PHONE_NUMBER$2, AUTHORIZATION_FAILED: AUTHORIZATION_FAILED$2, FAILED_PASSED_CAPTCHA: FAILED_PASSED_CAPTCHA$2, FAILED_PASSED_TWO_FACTOR: FAILED_PASSED_TWO_FACTOR$2 } = authErrors; /** * Blocked action * * @type {string} */ const ACTION_BLOCKED = 'act=blocked'; /** * Two-factor auth check action * * @type {string} */ const ACTION_AUTH_CODE$1 = 'act=authcheck'; /** * Phone number check action * * @type {string} */ const ACTION_SECURITY_CODE$2 = 'act=security'; /** * Number of two-factorial attempts * * @type {number} */ const TWO_FACTOR_ATTEMPTS$2 = 3; /** * Number of captcha attempts * * @type {number} */ const CAPTCHA_ATTEMPTS$1 = 3; /** * Removes the prefix * * @type {RegExp} */ const REPLACE_PREFIX_RE = /^[+|0]+/; /** * Find location.href text * * @type {RegExp} */ const FIND_LOCATION_HREF_RE = /location\.href\s+=\s+"([^"]+)"/i; class ImplicitFlow { /** * Constructor * * @param {VK} vk * @param {Object} options */ constructor(vk, { appId = vk.options.appId, appSecret = vk.options.appSecret, login = vk.options.login, phone = vk.options.phone, password = vk.options.password, agent = vk.options.agent, scope = vk.options.authScope, timeout = vk.options.authTimeout } = {}) { this.vk = vk; this.appId = appId; this.appSecret = appSecret; this.login = login; this.phone = phone; this.password = password; this.agent = agent; this.scope = scope; this.timeout = timeout; this.jar = new CookieJar(); this.started = false; this.captchaValidate = null; this.captchaAttempts = 0; this.twoFactorValidate = null; this.twoFactorAttempts = 0; } /** * Returns custom tag * * @return {string} */ get [Symbol.toStringTag]() { return this.constructor.name; } /** * Returns CookieJar * * @return {CookieJar} */ get cookieJar() { return this.jar; } /** * Sets the CookieJar * * @param {CookieJar} jar * * @return {this} */ set cookieJar(jar) { this.jar = jar; } /** * Returns cookie * * @return {Promise<Object>} */ async getCookies() { const { jar } = this; const getCookieString = promisify$1(jar.getCookieString).bind(jar); const [login, main] = await Promise.all([ getCookieString('https://login.vk.com'), getCookieString('https://vk.com') ]); return { 'login.vk.com': login, 'vk.com': main }; } /** * Executes the HTTP request * * @param {string} url * @param {Object} options * * @return {Promise<Response>} */ fetch(url, options = {}) { const { agent, timeout } = this; const { headers = {} } = options; return this.fetchCookie(url, { ...options, agent, timeout, compress: false, headers: { ...headers, 'User-Agent': DESKTOP_USER_AGENT } }); } /** * Runs authorization * * @return {Promise<Object>} */ // eslint-disable-next-line consistent-return async run() { if (this.started) { throw new AuthError({ message: 'Authorization already started!', code: AUTHORIZATION_FAILED$2 }); } this.started = true; this.fetchCookie = fetchCookieFollowRedirectsDecorator(this.jar); debug$4('get permissions page'); let response = await this.getPermissionsPage(); const isProcessed = true; while (isProcessed) { const { url } = response; debug$4('URL', url); if (url.includes(CALLBACK_BLANK)) { return { response }; } if (url.includes(ACTION_BLOCKED)) { debug$4('page blocked'); throw new AuthError({ message: 'Page blocked', code: PAGE_BLOCKED }); } const $ = cheerioLoad$2(await response.text()); if (url.includes(ACTION_AUTH_CODE$1)) { response = await this.processTwoFactorForm(response, $); continue; } if (url.includes(ACTION_SECURITY_CODE$2)) { response = await this.processSecurityForm(response, $); continue; } const $error = $('.box_error'); const $service = $('.service_msg_warning'); const isError = $error.length !== 0; if (this.captchaValidate === null && (isError || $service.length !== 0)) { const errorText = isError ? $error.text() : $service.text(); throw new AuthError({ message: `Auth form error: ${errorText}`, code: AUTHORIZATION_FAILED$2, pageHtml: $.html() }); } if ($('input[name="pass"]').length !== 0) { response = await this.processAuthForm(response, $); continue; } if (url.includes('act=')) { throw new AuthError({ message: 'Unsupported authorization event', code: AUTHORIZATION_FAILED$2, pageHtml: $.html() }); } debug$4('auth with login & pass complete'); if ($('form').length !== 0) { const { action } = parseFormField($); debug$4('url grant access', action); response = await this.fetch(action, { method: 'POST' }); } else { const locations = $.html().match(FIND_LOCATION_HREF_RE); if (locations === null) { throw new AuthError({ message: 'Could not log in', code: AUTHORIZATION_FAILED$2, pageHtml: $.html() }); } const location = locations[1].replace('&cancel=1', ''); debug$4('url grant access', location); response = await this.fetch(location, { method: 'POST' }); } } } /** * Process form auth * * @param {Response} response * @param {Cheerio} $ * * @return {Promise<Response>} */ async processAuthForm(response, $) { debug$4('process login handle'); if (this.captchaValidate !== null) { this.captchaValidate.reject(new AuthError({ message: 'Incorrect captcha code', code: FAILED_PASSED_CAPTCHA$2, pageHtml: $.html() })); this.captchaValidate = null; this.captchaAttempts += 1; } if (this.captchaAttempts > CAPTCHA_ATTEMPTS$1) { throw new AuthError({ message: 'Maximum attempts passage captcha', code: FAILED_PASSED_CAPTCHA$2 }); } const { login, password, phone } = this; const { action, fields } = parseFormField($); fields.email = login || phone; fields.pass = password; if ('captcha_sid' in fields) { const src = $('.oauth_captcha').attr('src') || $('#captcha').attr('src'); const { key, validate } = await this.vk.callbackService.processingCaptcha({ type: captchaTypes.IMPLICIT_FLOW_AUTH, sid: fields.captcha