@addtodoist/twitter-autohook
Version:
Automatically setup and serve webhooks for the Twitter Account Activity API
290 lines (289 loc) • 11.8 kB
JavaScript
import { TooManySubscriptionsError, UserSubscriptionError, WebhookURIError, tryError, } from './errors/index.js';
import { del, get, post } from './client/index.js';
import EventEmitter from 'events';
import bearerToken from './bearer-token/index.js';
import crypto from 'crypto';
import dotenv from 'dotenv';
import http from 'http';
import ngrok from 'ngrok';
import path from 'path';
import url from 'url';
dotenv.config({ path: path.resolve(process.cwd(), '.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}…`);
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 {
auth;
ngrokSecret;
env;
port;
headers;
server;
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(), ngrok_secret = (process.env.NGROK_AUTH_TOKEN || '').trim(), env = (process.env.TWITTER_WEBHOOK_ENV || '').trim(), port = Number(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.ngrokSecret = ngrok_secret;
this.env = env;
this.port = port;
this.headers = headers;
}
async startServer() {
const server = http.createServer((req, res) => {
const route = url.parse(req.url, true);
if (!route.pathname) {
return;
}
if (route.pathname === '/status') {
res.statusCode = 200;
res.end();
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);
await new Promise((resolve, reject) => {
server.on('listening', resolve).once('error', reject);
});
this.server = server;
}
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 WebhookURIError(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 getWebhooks() {
console.log('Getting webhooks…');
let token = null;
try {
token = await bearerToken(this.auth);
}
catch (e) {
token = null;
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 WebhookURIError(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();
await deleteWebhooks(webhooks, this.auth, this.env);
}
async start(webhookUrl = null) {
if (!webhookUrl) {
this.startServer();
if (this.ngrokSecret) {
await ngrok.authtoken(this.ngrokSecret);
}
const url = await ngrok.connect(this.port);
webhookUrl = `${url}${WEBHOOK_ROUTE}`;
}
try {
await this.setWebhook(webhookUrl);
console.log('Webhook created.');
}
catch (e) {
console.log('Cannot create webhook:', 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) {
screen_name = null;
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;
}
}
export { Autohook, WebhookURIError, UserSubscriptionError, TooManySubscriptionsError, validateWebhook, validateSignature, };