twitchWebSub.js

'use strict';

const request = require('request-promise');
const util = require('util');
const eventemitter = require('eventemitter3');
const _ = require('./helpers');

const API_BASE_URL = 'https://api.twitch.tv/helix';

/**
 * @class TwitchWebSub
 * The Twitch Webhooks implementation, as described in https://dev.twitch.tv/docs/api/webhooks-guide/ .
 * The Webhook/WebSub requires a public endpoint on the running express server/application to receive the data from the hub. Without this, its impossible to make this work.
 *
 * @param {object} config The config object.
 * @param {string} config.client_id The client ID of the user to be passed to the Hub (un)subscribe requests.
 * @param {string} config.callbackUrl The callback URL that will receive the Hub requests. These requests should be forwarded to the handleRequest method to properly handle these data.
 * @param {object} logger The logger object.
 */
function TwitchWebSub(config, logger) {
    eventemitter.call(this);
    this.config = config;
    this.logger = logger;

    this.secret = _.uuidv4();
    this.subscribersMap = {};
}

/**
 * Notifies when a follows event occurs. The response mimics the Get Users Follows endpoint. (https://dev.twitch.tv/docs/api/reference/#get-users-follows)
 *
 * @param {number} fromId The ID of the user who starts following someone.
 * @param {number} toId The ID of the user who has a new follower.
 * @return {string} The subscription ID, used to identify the active topic.
 * @fires TwitchWebSub#WebSub:user_follows
 */
TwitchWebSub.prototype.topicUserFollowsSubscribe = async function(
    fromId,
    toId
) {
    /**
     * Stream User Follows Event
     * @event TwitchWebSub#WebSub:user_follows
     * @param {object} data The data object received from the Hub.
     * @param {string} id The subscription ID.
     */
    try {
        let topic = API_BASE_URL + '/users/follows?first=1';
        if (fromId) {
            topic += '&from_id=' + fromId;
        } else if (toId) {
            topic += '&to_id=' + toId;
        }
        return this.subscribe(topic, 'user_follows');
    } catch (err) {
        throw err;
    }
};

/**
 * Notifies when a stream goes online or offline. The response mimics the Get Streams endpoint. (https://dev.twitch.tv/docs/api/reference/#get-streams)
 *
 * @param {number} streamUserId The ID of the user whose stream is monitored.
 * @return {string} The subscription ID, used to identify the active topic.
 * @fires TwitchWebSub#WebSub:stream_up_down
 */
TwitchWebSub.prototype.topicStreamUpDownSubscribe = async function(
    streamUserId
) {
    /**
     * Stream Up/Down Event
     * @event TwitchWebSub#WebSub:stream_up_down
     * @param {object} data The data object received from the Hub.
     * @param {string} id The subscription ID.
     */
    try {
        let topic = API_BASE_URL + '/streams?user_id=' + streamUserId;
        return this.subscribe(topic, 'stream_up_down');
    } catch (err) {
        throw err;
    }
};

/**
 * Subscribe to a specific topic that will fires an event when new data is received from the hub.
 * The event handler will receive the data object and the subscription ID.
 * @param {string} topic The topic name/URL.
 * @param {string} eventName The event name that will be fired when new data is received.
 * @return {string} The subscription ID, used to identify the active topic.
 */
TwitchWebSub.prototype.subscribe = async function(topic, eventName) {
    try {
        this.logger.debug('Subscribing WebSub with topic: ' + topic);
        let item = {
            id: _.uuidv4(),
            topic: topic,
            eventName: eventName,
            subscribedAt: new Date(),
            secret: _.generateRandomKey()
        };

        await request({
            url: API_BASE_URL + '/webhooks/hub',
            method: 'POST',
            headers: {
                'Client-ID': this.config.client_id,
                'Content-Type': 'application/json'
            },
            form: {
                'hub.callback': this.config.callbackUrl + '?item.id=' + item.id,
                'hub.mode': 'subscribe',
                'hub.topic': topic,
                'hub.lease_seconds': 864000,
                'hub.secret': item.secret
            },
            json: true
        });

        this.subscribersMap[item.id] = item;
        return item.id;
    } catch (err) {
        throw err;
    }
};

/**
 * @private
 * This method will handle the request received by the express server and validate the subscriptions or properly emit the events with the received data.
 * @param {object} request The express request object.
 * @param {object} response The express response object.
 */
TwitchWebSub.prototype.handleRequest = function(request, response) {
    try {
        this.logger.debug('Receiving new WebSub request');
        if (request.query['hub.challenge']) {
            response.send(request.query['hub.challenge']);
        } else {
            let id = request.query['item.id'];
            if (id && this.subscribersMap[id]) {
                let item = this.subscribersMap[id];
                let data = request.body ? request.body.data : null;

                //TODO Validate secret
                response.sendStatus(200);
                this.emit(item.eventName, data, id);
            } else {
                response.sendStatus(400);
            }
        }
    } catch (err) {
        response.sendStatus(500);
        throw err;
    }
};

/**
 * Unsubscribe from a topic by its ID.
 * @param {string} id The subscription ID.
 */
TwitchWebSub.prototype.unsubscribe = async function(id) {
    try {
        this.logger.debug('Requesting WebSub unsubscription with id: ' + id);
        if (this.subscribersMap[id]) {
            let item = this.subscribersMap[id];
            await request({
                url: API_BASE_URL + '/webhooks/hub',
                method: 'POST',
                headers: {
                    'Client-ID': this.config.client_id,
                    'Content-Type': 'application/json'
                },
                form: {
                    'hub.callback':
                        this.config.callbackUrl + '?item.id=' + item.id,
                    'hub.mode': 'unsubscribe',
                    'hub.topic': item.topic,
                    'hub.secret': item.secret
                },
                json: true
            });
            this.logger.debug('WebSub unsubscribed: ' + id);
        } else {
            this.logger.warn('Unable to find subscription with id ' + id);
        }
    } catch (err) {
        throw err;
    }
};

/**
 * Destroy the WebSub, unsubscribing every active subscription.
 */
TwitchWebSub.prototype.destroy = async function() {
    try {
        this.logger.info('Destroying TwitchWebSub...');
        for (const key in this.subscribersMap) {
            if (this.subscribersMap.hasOwnProperty(key)) {
                let item = this.subscribersMap[key];
                this.logger.debug(
                    'Unsubscribing user with topic: ' + item.topic
                );
                await this.unsubscribe(item.id);
            }
        }
        this.subscribersMap = {};
        this.logger.info('TwitchWebSub destroyed.');
    } catch (err) {
        throw err;
    }
};

util.inherits(TwitchWebSub, eventemitter);
module.exports = TwitchWebSub;