#!/usr/bin/env node const ngrok = require('ngrok'); const http = require('http'); const url = require('url'); const crypto = require('crypto'); const path = require('path'); const os = require('os'); const EventEmitter = require('events'); const URL = require('url').URL; const bearerToken = require('./bearer-token'); const { get, post, del } = require('./client'); const { TooManySubscriptionsError, UserSubscriptionError, WebhookURIError, tryError, } = require('./errors'); require('dotenv').config({path: path.resolve(os.homedir(), '.env.twitter')}); const DEFAULT_PORT = 1337; const WEBHOOK_ROUTE = '/webhook'; let _getSubscriptionsCount = null; const getSubscriptionsCount = async (auth) => { if (_getSubscriptionsCount) { return _getSubscriptionsCount; } const token = await bearerToken(auth); const requestConfig = { url: 'https://api.twitter.com/1.1/account_activity/all/subscriptions/count.json', options: { bearer: token }, }; const response = await get(requestConfig); const error = tryError(response); if (error) { throw error; } _getSubscriptionsCount = response.body; return _getSubscriptionsCount; } const updateSubscriptionCount = increment => { if (!_getSubscriptionsCount) { return; } _getSubscriptionsCount.subscriptions_count += increment; } const deleteWebhooks = async (webhooks, auth, env) => { console.log('Removing webhooks…'); for (const {id, url} of webhooks) { const requestConfig = { url: `https://api.twitter.com/1.1/account_activity/all/${env}/webhooks/${id}.json`, options: { oauth: auth, }, } console.log(`Removing ${url}…`); const response = await del(requestConfig); } } const validateWebhook = (token, auth) => { const responseToken = crypto.createHmac('sha256', auth.consumer_secret).update(token).digest('base64'); return {response_token: `sha256=${responseToken}`}; } const validateSignature = (header, auth, body) => { const signatureHeaderName = 'x-twitter-webhooks-signature'; if (typeof header[signatureHeaderName] === 'undefined') { throw new TypeError(`validateSignature: header ${signatureHeaderName} not found`); } const signature = 'sha256=' + crypto .createHmac('sha256', auth.consumer_secret) .update(body) .digest('base64'); return crypto.timingSafeEqual( Buffer.from(header[signatureHeaderName]), Buffer.from(signature)); } const verifyCredentials = async (auth) => { const requestConfig = { url: 'https://api.twitter.com/1.1/account/verify_credentials.json', options: { oauth: auth, }, }; const response = await get(requestConfig); const error = tryError( response, (response) => new UserSubscriptionError(response)); if (error) { throw error; } return response.body.screen_name; } class Autohook extends EventEmitter { constructor({ token = (process.env.TWITTER_ACCESS_TOKEN || '').trim(), token_secret = (process.env.TWITTER_ACCESS_TOKEN_SECRET || '').trim(), consumer_key = (process.env.TWITTER_CONSUMER_KEY || '').trim(), consumer_secret = (process.env.TWITTER_CONSUMER_SECRET || '').trim(), env = (process.env.TWITTER_WEBHOOK_ENV || '').trim(), port = process.env.PORT || DEFAULT_PORT, headers = [], } = {}) { Object.entries({token, token_secret, consumer_key, consumer_secret, env, port}).map(el => { const [key, value] = el; if (!value) { throw new TypeError(`'${key}' is empty or not set. Check your configuration and try again.`); } }); super(); this.auth = {token, token_secret, consumer_key, consumer_secret}; this.env = env; this.port = port; this.headers = headers; } startServer() { this.server = http.createServer((req, res) => { const route = url.parse(req.url, true); if (!route.pathname) { return; } if (route.query.crc_token) { try { if (!validateSignature(req.headers, this.auth, url.parse(req.url).query)) { console.error('Cannot validate webhook signature'); return; }; } catch (e) { console.error(e); } const crc = validateWebhook(route.query.crc_token, this.auth); res.writeHead(200, {'content-type': 'application/json'}); res.end(JSON.stringify(crc)); } if (req.method === 'POST' && req.headers['content-type'] === 'application/json') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { try { if (!validateSignature(req.headers, this.auth, body)) { console.error('Cannot validate webhook signature'); return; }; } catch (e) { console.error(e); } this.emit('event', JSON.parse(body), req); res.writeHead(200); res.end(); }); } }).listen(this.port); } async setWebhook(webhookUrl) { const parsedUrl = url.parse(webhookUrl); if (parsedUrl.protocol === null || parsedUrl.host === 'null') { throw new TypeError(`${webhookUrl} is not a valid URL. Please provide a valid URL and try again.`); } else if (parsedUrl.protocol !== 'https:') { throw new TypeError(`${webhookUrl} is not a valid URL. Your webhook must be HTTPS.`); } console.log(`Registering ${webhookUrl} as a new webhook…`); const endpoint = new URL(`https://api.twitter.com/1.1/account_activity/all/${this.env}/webhooks.json`); endpoint.searchParams.append('url', webhookUrl); const requestConfig = { url: endpoint.toString(), options: { oauth: this.auth, }, } const response = await post(requestConfig); const error = tryError( response, (response) => new URIError(response, [ `Cannot get webhooks. Please check that '${env}' is a valid environment defined in your`, `Developer dashboard at https://developer.twitter.com/en/account/environments, and that`, `your OAuth credentials are valid and can access '${env}'. (HTTP status: ${response.statusCode})`].join(' ')) ); if (error) { throw error; } return response.body; } async getWebhooks() { console.log('Getting webhooks…'); let token = null; try { token = await bearerToken(this.auth); } catch (e) { throw e; } const requestConfig = { url: `https://api.twitter.com/1.1/account_activity/all/${this.env}/webhooks.json`, options: { bearer: token, }, }; const response = await get(requestConfig); const error = tryError( response, (response) => new URIError(response, [ `Cannot get webhooks. Please check that '${this.env}' is a valid environment defined in your`, `Developer dashboard at https://developer.twitter.com/en/account/environments, and that`, `your OAuth credentials are valid and can access '${this.env}'. (HTTP status: ${response.statusCode})`].join(' '))); if (error) { throw error; } return response.body; } async removeWebhook(webhook) { await deleteWebhooks([webhook], this.auth, this.env); } async removeWebhooks() { const webhooks = await this.getWebhooks(this.auth, this.env); await deleteWebhooks(webhooks, this.auth, this.env); } async start(webhookUrl = null) { if (!webhookUrl) { this.startServer(); const url = await ngrok.connect(this.port); webhookUrl = `${url}${WEBHOOK_ROUTE}`; } try { await this.setWebhook(webhookUrl); console.log('Webhook created.'); } catch(e) { throw e; } } async subscribe({oauth_token, oauth_token_secret, screen_name = null}) { const auth = { consumer_key: this.auth.consumer_key, consumer_secret: this.auth.consumer_secret, token: oauth_token.trim(), token_secret: oauth_token_secret.trim(), }; try { screen_name = screen_name || await verifyCredentials(auth); } catch (e) { throw e; } const {subscriptions_count, provisioned_count} = await getSubscriptionsCount(auth); if (subscriptions_count === provisioned_count) { throw new TooManySubscriptionsError([`Cannot subscribe to ${screen_name}'s activities:`, 'you exceeded the number of subscriptions available to you.', 'Please remove a subscription or upgrade your premium access at', 'https://developer.twitter.com/apps.', ].join(' ')); } const requestConfig = { url: `https://api.twitter.com/1.1/account_activity/all/${this.env}/subscriptions.json`, options: { oauth: auth, }, }; const response = await post(requestConfig); const error = tryError( response, (response) => new UserSubscriptionError(response)); if (error) { throw error; } console.log(`Subscribed to ${screen_name}'s activities.`); updateSubscriptionCount(1); return true; } async unsubscribe(userId) { const token = await bearerToken(this.auth); const requestConfig = { url: `https://api.twitter.com/1.1/account_activity/all/${this.env}/subscriptions/${userId}.json`, options: { bearer: token }, }; const response = await del(requestConfig); const error = tryError( response, (response) => new UserSubscriptionError(response)); if (error) { throw error; } console.log(`Unsubscribed from ${userId}'s activities.`); updateSubscriptionCount(-1); return true; } } module.exports = { Autohook, WebhookURIError, UserSubscriptionError, TooManySubscriptionsError, validateWebhook, validateSignature, };