tesjs
Version:
A module to streamline the use of Twitch EventSub in Node.js and Web applications
498 lines (467 loc) • 20.9 kB
JavaScript
const whserver = require("./whserver");
const WebSocketClient = require("./wsclient");
const EventManager = require("./events");
const AuthManager = require("./auth");
const RequestManager = require("./request");
const { objectShallowEquals, printObject } = require("./utils");
const logger = require("./logger");
const SUBS_API_URL = "https://api.twitch.tv/helix/eventsub/subscriptions";
/**
* @typedef {Object} TES~Config
* @param {TES~Config~Options} [options] Basic configuration options
* @param {TES~Config~Identity} identity Identity information
* @param {TES~Config~Listener} listener Your notification listener details
*/
/**
* Basic configuration options
*
* @typedef {Object} TES~Config~Options
* @param {boolean} [debug=false] Set to true for in-depth logging
* @param {boolean} [logging=true] Set to false for no logging
*/
/**
* Identity configuration
*
* @typedef {Object} TES~Config~Identity
* @param {string} id Your client ID
* @param {string} [secret] Your client secret, required for webhook transport or
* when not using `onAuthenticationFailure` in server-side `websocket` applications
* @param {TES~Config~Identity~onAuthenticationFailure} [onAuthenticationFailure] Callback function called
* when API requests get an auth failure. If you already have an authentication solution for your app
* elsewhere use this to avoid token conflicts
* @param {string} [accessToken] If you already have an access token, put it here. Must
* be user access token for `websocket` transport, must be app access token for `webhook` transport. Should
* usually be paired with `onAuthenticationFailure` on server-side applications
* @param {string} [refreshToken] The refresh token to use if using `websocket` transport
* server-side. Required when not using `onAuthenticationFailure` in server-side `websocket` applications
*/
/**
* Callback function called when API requests get an auth failure. If you already have an authentication solution
* for your app elsewhere use this to avoid token conflicts
*
* @callback TES~Config~Identity~onAuthenticationFailure
* @returns {Promise} Promise that resolves a new access token
* @example
* ```js
* async function onAuthenticationFailure() {
* const res = await getNewAccessToken(); // your token refresh logic
* return res.access_token;
* }
* ```
*/
/**
* Listener configuration
*
* @typedef {Object} TES~Config~Listener
* @param {"webhook"|"websocket"} type The type of transport to use
* @param {string} [baseURL] Required for `webhook` transport. The base URL where your app is
* hosted. See [Twitch doc](https://dev.twitch.tv/docs/eventsub) for details on local development
* @param {string} [websocketURL=wss://eventsub.wss.twitch.tv/ws] A custom websocket URL to use for `websocket` transport. Useful for
* local testing with [Twitch CLI](https://dev.twitch.tv/docs/cli/)
* @param {string} [secret] Required for `webhook` transport. The secret to use for your `webhook`
* subscriptions. Should be different from your client secret
* @param {Express} [server] The Express app object. Use if integrating with an existing Express app
* @param {number} [port=process.env.PORT,8080] A custom port to use
* @param {boolean} [ignoreDuplicateMessages=true] Ignore event messages with IDs that have already
* been seen. Only used in `webhook` transport
* @param {boolean} [ignoreOldMessages=true] Ignore event messages with timestamps older than ten
* minutes. Only used in `webhook` transport
*/
/**
* @license
* Copyright (c) 2020-2023 Mitchell Adair
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
class TES {
/**
* @typicalname tes
* @param {TES~Config} config The TES configuration
* @returns {TES} The TESjs instance
* @example
* Minimum `websocket` config
* ```js
* const config = {
* identity: {
* id: YOUR_CLIENT_ID,
* accessToken: YOUR_USER_ACCESS_TOKEN,
* }
* listener: { type: "websocket" },
* };
* const tes = new TES(config);
* ```
* @example
* Minimum `webhook` config
* ```js
* const config = {
* identity: {
* id: YOUR_CLIENT_ID,
* secret: YOUR_CLIENT_SECRET,
* },
* listener: {
* type: "webhook",
* baseURL: "https://example.com",
* secret: YOUR_WEBHOOKS_SECRET,
* },
* };
* const tes = new TES(config);
* ```
*/
constructor(config) {
// TES singleton
if (TES._instance) return TES._instance;
// ensure we have an identity
if (!config.identity) throw new Error("TES config must contain 'identity'");
if (!config.listener) throw new Error("TES config must contain 'listener'");
const {
identity: { id, secret, onAuthenticationFailure, accessToken, refreshToken },
listener: {
type,
baseURL,
secret: whSecret,
port,
ignoreDuplicateMessages,
ignoreOldMessages,
server,
websocketURL,
},
} = config;
if (!type || (type !== "webhook" && type !== "websocket")) {
throw new Error("TES listener config must have 'type' either 'webhook' or 'websocket'");
}
if (!id) throw new Error("TES identity config must contain 'id'");
if (type === "webhook") {
if (!secret) throw new Error("TES identity config must contain 'secret'");
if (!baseURL) throw new Error("TES listener config must contain 'baseURL'");
if (!whSecret) throw new Error("TES listener config must contain 'secret'");
} else {
if (!accessToken) throw new Error("TES identity config must contain 'accessToken'");
if (typeof window === "undefined" && !onAuthenticationFailure && !refreshToken) {
throw new Error("TES identity config must contain either 'onAuthenticationFailure' or 'refreshToken'");
}
if (refreshToken && !secret) {
throw new Error("TES identity config must contain 'secret'");
}
}
TES._instance = this;
this.clientID = id;
this.transportType = type;
if (type === "webhook") {
this.baseURL = baseURL;
this.whSecret = whSecret;
this.port = port || process.env.PORT || 8080;
const serverConfig = {
ignoreDuplicateMessages: ignoreDuplicateMessages === false ? false : true,
ignoreOldMessages: ignoreOldMessages === false ? false : true,
};
this.whserver = whserver(server, whSecret, serverConfig);
this._whserverlistener = server ? null : this.whserver.listen(this.port);
} else {
this.wsclient = new WebSocketClient(websocketURL);
}
config.options = config.options || {};
config.options.debug && logger.setLevel("debug");
config.options.logging === false && logger.setLevel("none");
new AuthManager({
clientID: id,
clientSecret: secret,
onAuthFailure: onAuthenticationFailure,
initialToken: accessToken,
refreshToken,
});
}
/**
* Get a list of your event subscriptions
*
* @param {string} [cursor] The pagination cursor
* @returns {Promise} Subscription data. See [Twitch doc](https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions) for details
* @example
* ```js
* const subs = await tes.getSubscriptions();
* console.log(`I have ${subs.total} event subscriptions`);
* ```
*/
getSubscriptions(cursor) {
logger.debug(`Getting ${cursor ? `subscriptions for cursor ${cursor}` : "first page of subscriptions"}`);
return this._getSubs(`${SUBS_API_URL}${cursor ? `?after=${cursor}` : ""}`);
}
/**
* Get a list of your event subscriptions by type
*
* @param {string} type The type of subscription. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#subscription-types) for details
* @param {string} [cursor] The pagination cursor
* @returns {Promise} Subscription data. See [Twitch doc](https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions) for details
* @example
* ```js
* const subs = await tes.getSubscriptionsByType("channel.update");
* console.log(`I have ${subs.total} "channel.update" event subscriptions`);
* ```
*/
getSubscriptionsByType(type, cursor) {
logger.debug(
`Getting ${cursor ? `subscriptions for cursor ${cursor}` : "first page of subscriptions"} of type ${type}`
);
return this._getSubs(
`${SUBS_API_URL}?${`type=${encodeURIComponent(type)}`}${cursor ? `&after=${cursor}` : ""}`
);
}
/**
* Get a list of your event subscriptions by status
*
* @param {string} status The subscription status. See [Twitch doc](https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions) for details
* @param {string} [cursor] The pagination cursor
* @returns {Promise} Subscription data. See [Twitch doc](https://dev.twitch.tv/docs/api/reference/#get-eventsub-subscriptions) for details
* @example
* ```js
* const subs = await tes.getSubscriptionsByStatus("enabled");
* console.log(`I have ${subs.total} "enabled" event subscriptions`);
* ```
*/
getSubscriptionsByStatus(status, cursor) {
logger.debug(
`Getting ${
cursor ? `subscriptions for cursor ${cursor}` : "first page of subscriptions"
} with status ${status}`
);
return this._getSubs(
`${SUBS_API_URL}?${`status=${encodeURIComponent(status)}`}${cursor ? `&after=${cursor}` : ""}`
);
}
/**
* Get subscription data for an individual subscription. Search either by id or by type and condition
*
* @signature `getSubscription(id)`
* @signature `getSubscription(type, condition)`
* @param {string} idOrType The subscription id or [type](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#subscription-types)
* @param {Object} [condition] The subscription condition, required when finding by type. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-reference/#conditions) for details
* @returns {Promise} The subscription data
* @example
* Find a subscription by id
* ```js
* const sub = await tes.getSubscription("2d9e9f1f-39c3-426d-88f5-9f0251c9bfef");
* console.log(`The status for subscription ${sub.id} is ${sub.status}`);
* ```
* @example
* Find a subscription by type and condition
* ```js
* const condition = { broadcaster_user_id: "1337" };
* const sub = await tes.getSubscription("channel.update", condition);
* console.log(`The status for subscription ${sub.id} is ${sub.status}`);
* ```
*/
async getSubscription(idOrType, condition) {
if (condition) {
logger.debug(`Getting subscription for type ${idOrType} and condition ${printObject(condition)}`);
} else {
logger.debug(`Getting subscription for id ${idOrType}`);
}
let sub;
const getUntilFound = async (cursor) => {
let res;
if (condition) {
res = await this.getSubscriptionsByType(idOrType, cursor);
} else {
res = await this.getSubscriptions(cursor);
}
const { data, pagination } = res;
sub = data.find((s) => {
if (condition) {
return s.type === idOrType && objectShallowEquals(s.condition, condition);
} else {
return s.id === idOrType;
}
});
if (!sub && pagination.cursor) {
await getUntilFound(pagination.cursor);
}
};
await getUntilFound();
return sub;
}
/**
* Subscribe to an event
*
* @param {string} type The subscription type. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#subscription-types) for details
* @param {Object} condition The subscription condition. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-reference/#conditions) for details
* @param {string} [version] The subscription version. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#subscription-types) for details
* @returns {Promise} A Promise that resolves when subscribing is complete with the subscription data
* @example
* ```js
* const condition = { broadcaster_user_id: "1337" };
* const sub = tes.subscribe("channel.update", condition);
* console.log(`Created subscription to ${sub.type}, subscription id ${sub.id}`);
* ```
*/
async subscribe(type, condition, version = "1") {
logger.debug(`Subscribing to topic with type ${type} and condition ${printObject(condition)}`);
const token = await AuthManager.getInstance().getToken();
const headers = {
"client-id": this.clientID,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
};
let transport = {
method: this.transportType,
};
if (this.transportType === "webhook") {
transport.callback = `${this.baseURL}/teswh/event`;
transport.secret = this.whSecret;
} else {
const session = await this.wsclient.getFreeConnection();
transport.session_id = session;
}
const body = {
type,
condition,
transport,
version,
};
const data = await RequestManager.request(SUBS_API_URL, {
method: "POST",
body: JSON.stringify(body),
headers,
});
if (data.data) {
if (this.transportType === "webhook") {
return new Promise((resolve, reject) => EventManager.queueSubscription(data, resolve, reject));
} else {
const subscription = data.data[0];
this.wsclient.addSubscription(subscription.transport.session_id, subscription);
return subscription;
}
} else {
const { error, status, message } = data;
throw new Error(`${status} ${error}: ${message}`);
}
}
/**
* Unsubscribe from an event. Unsubscribe either by id, or by type and condition
*
* @signature `unsubscribe(id)`
* @signature `unsubscribe(type, condition)`
* @param {string} idOrType The subscription id or [type](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#subscription-types)
* @param {Object} [condition] The subscription condition, required when finding by type. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-reference/#conditions) for details
* @returns {Promise} Resolves when unsubscribed
* @example
* Unsubscribe by id
* ```js
* await tes.unsubscribe("2d9e9f1f-39c3-426d-88f5-9f0251c9bfef");
* console.log("Successfully unsubscribed");
* ```
* @example
* Unsubscribe by type and condition
* ```js
* const condition = { broadcaster_user_id: "1337" };
* await tes.unsubscribe("channel.update", condition);
* console.log("Successfully unsubscribed");
* ```
*/
async unsubscribe(idOrType, condition) {
const token = await AuthManager.getInstance().getToken();
const headers = {
"client-id": this.clientID,
Authorization: `Bearer ${token}`,
};
const unsub = async (id) => {
return RequestManager.request(`${SUBS_API_URL}?id=${id}`, { method: "DELETE", headers }, false);
};
if (condition) {
logger.debug(`Unsubscribing from topic with type ${idOrType} and condition ${printObject(condition)}`);
let id;
if (this.transportType === "webhook") {
const sub = await this.getSubscription(idOrType, condition);
if (sub) {
id = sub.id;
}
} else {
id = this.wsclient.findSubscriptionID(idOrType, condition);
}
if (id) {
if (this.transportType === "webhook") {
return unsub(id);
} else {
const res = await unsub(id);
if (res.ok) {
this.wsclient.removeSubscription(id);
}
return res;
}
} else {
throw new Error("subscription with given type and condition not found");
}
} else {
logger.debug(`Unsubscribing from topic ${idOrType}`);
return unsub(idOrType);
}
}
/**
* Called when an event TES is listening for is triggered. See [TES.on](#TES+on) for examples
*
* @callback TES~onEventCallback
* @param {Object} [event] The event data. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-reference/#events) for details.
* See the [TES.on](#TES+on) examples for details on `revocation` and `connection_lost`
* @param {Object} [subscription] The subscription data corresponding to the event. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-reference/#subscription) for details
* @returns {void}
*/
/**
* Add an event handler. This will handle ALL events of the type
*
* @param {string|"revocation"|"connection_lost"} type The subscription type. See [Twitch doc](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#subscription-types) for details.
* See the examples for details on `revocation` and `connection_lost`
* @param {TES~onEventCallback} callback The function to call when the event happens
* @returns {void}
* @example
* ```js
* tes.on("channel.update", (event, subscription) => {
* console.log(`Event triggered for subscription ${subscription.id}`);
* console.log(`${event.broadcaster_user_id}'s title is now "${event.title}"`);
* });
* ```
* @example
* The `revocation` event is fired when Twitch revokes a subscription. This can happen
* for various reasons [according to Twitch](https://dev.twitch.tv/docs/eventsub/handling-webhook-events/#revoking-your-subscription).
* The "event" argument is the subscription data. This means that for this, the first and second arguments are basically identical
*
* **NOTE**: No explicit subscription is needed for this event to be fired
* ```js
* tes.on("revocation", (subscriptionData) => {
* console.log(`Subscription ${subscriptionData.id} has been revoked`);
* // perform necessary cleanup here
* });
* ```
* @example
* The `connection_lost` event is fired when a WebSocket connection is lost. All related
* subscriptions should be considered stale if this happens. You can read more about this case
* in the [Twitch doc](https://dev.twitch.tv/docs/eventsub/handling-websocket-events/#keepalive-message).
* The "event" argument is an `Object` which has subscription ids as keys and type and condition as the values
*
* **NOTE**: No explicit subscription is needed for this event to be fired
* ```js
* tes.on("connection_lost", (subscriptions) => {
* // if your subscriptions are important to you, resubscribe to them
* Object.values(subscriptions).forEach((subscription) => {
* tes.subscribe(subscription.type, subscription.condition);
* });
* });
* ```
*/
on(type, callback) {
logger.debug(`Adding notification listener for type ${type}`);
EventManager.addListener(type, callback);
}
async _getSubs(url) {
const token = await AuthManager.getInstance().getToken();
const headers = {
"client-id": this.clientID,
Authorization: `Bearer ${token}`,
};
return RequestManager.request(url, { headers });
}
static ignoreInMiddleware(middleware) {
return (req, res, next) => {
return req.path === "/teswh/event" ? next() : middleware(req, res, next);
};
}
}
module.exports = TES;