A suite to track Project Diva score statistics and ratings / D4DJ event data.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
projectdivar/server/node_modules/twitter-autohook/index.js

353 lines
9.7 KiB

4 years ago
#!/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,
};