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.
353 lines
9.7 KiB
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,
|
||
|
};
|